Merge pull request #6114 from open-sausages/pulls/4.0/many-many-through

API Implement many_many through
This commit is contained in:
Sam Minnée 2016-10-06 11:04:23 +01:00 committed by GitHub
commit 8a2dddb01d
32 changed files with 2114 additions and 931 deletions

View File

@ -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

View File

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

View File

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

View File

@ -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') {

View File

@ -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 {

View File

@ -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;

View File

@ -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

View File

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

View File

@ -153,6 +153,13 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity
*/
protected $record;
/**
* If selected through a many_many through relation, this is the instance of the through record
*
* @var DataObject
*/
protected $joinRecord;
/**
* Represents a field that hasn't changed (before === after, thus before == after)
*/
@ -212,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();
/**
@ -270,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);
}
/**
@ -374,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
);
}
@ -410,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);
}
}
@ -471,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);
@ -556,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;
}
@ -573,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;
}
@ -600,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");
}
@ -638,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()) {
@ -806,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}";
}
@ -959,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') {
@ -1025,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) {
@ -1039,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;
}
@ -1147,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;
}
}
@ -1176,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(
@ -1239,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);
@ -1316,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
@ -1422,13 +1363,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;
}
@ -1491,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);
}
/**
@ -1508,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 === 'SilverStripe\\ORM\\DataObject') {
if($class === self::class) {
$class = $this->getField($componentName . 'Class');
if(empty($class)) return null;
}
@ -1529,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) {
@ -1581,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'",
@ -1600,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);
@ -1624,12 +1570,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->getSchema()->manyManyComponent(static::class, $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,9 +1636,9 @@ 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);
$schema = static::getSchema();
// Validate arguments
if(!$this->isInDB()) {
@ -1698,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",
@ -1710,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
));
}
@ -1720,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);
@ -1732,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" .
@ -1755,13 +1709,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)
= $remote->manyManyComponent($remoteRelation);
$extraFields = $remote->manyManyExtraFieldsForComponent($remoteRelation) ?: array();
list($relationClass, $componentClass, $parentClass, $componentField, $parentField, $table)
= $remote->getSchema()->manyManyComponent($remoteClass, $remoteRelation);
$extraFields = $schema->manyManyExtraFieldsForComponent($remoteClass, $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);
}
@ -1779,96 +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) {
// 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';
}
}
/**
* 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);
$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'",
@ -1877,7 +1751,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) {
@ -1888,9 +1763,16 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity
return $this->unsavedRelations[$componentName];
}
$extraFields = $this->manyManyExtraFieldsForComponent($componentName) ?: array();
/** @var ManyManyList $result */
$result = ManyManyList::create($componentClass, $table, $componentField, $parentField, $extraFields);
$extraFields = $schema->manyManyExtraFieldsForComponent(static::class, $componentName) ?: array();
/** @var RelationList $result */
$result = Injector::inst()->create(
$relationClass,
$componentClass,
$tableOrClass,
$componentField,
$parentField,
$extraFields
);
// Store component data in query meta-data
@ -1923,42 +1805,15 @@ 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) {
$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;
}
/**
* 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);
@ -1967,87 +1822,15 @@ 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) {
$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 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) {
$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;
} else {
return $db;
}
}
/**
* 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);
@ -2056,25 +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) {
$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 the many-to-many extra fields specification.
*
@ -2087,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() {
@ -2148,81 +1866,6 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity
return $items;
}
/**
* Return information about a specific many_many component. Returns a numeric array of:
* array(
* <classname>, The class that relation is defined in e.g. "Product"
* <candidateName>, The target class of the relation e.g. "Category"
* <parentField>, The field name pointing to <classname>'s table e.g. "ProductID"
* <childField>, The field name pointing to <candidatename>'s table e.g. "CategoryID"
* <joinTable> The join table between the two classes e.g. "Product_Categories"
* )
* @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;
}
/**
* This returns an array (if it exists) describing the database extensions that are required, or false if none
*
@ -2440,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) {
@ -2456,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);
}
@ -2499,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);
@ -2508,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;
}
@ -2582,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;
}
@ -2661,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
@ -2672,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;
}
@ -2714,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;
}
@ -2741,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}")
);
}
@ -2757,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);
}
/**
@ -2827,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;
@ -2990,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;
}
@ -3139,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 <classname>::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 <classname>::get() then you shouldn\'t pass any other'
. ' arguments');
}
@ -3226,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;
}
@ -3264,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();
}
@ -3387,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;
}
@ -3401,16 +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();
// Validate relationship configuration
$this->validateModelDefinitions();
if($fields) {
$hasAutoIncPK = get_parent_class($this) === 'SilverStripe\ORM\DataObject';
$hasAutoIncPK = get_parent_class($this) === self::class;
DB::require_table(
$table, $fields, $indexes, $hasAutoIncPK, $this->stat('create_table_options'), $extensions
);
@ -3421,29 +3015,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 = $schema->manyManyComponent(static::class, $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 +3050,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
@ -3532,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;
@ -3541,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;
}
}
@ -3630,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)
);
@ -3689,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);
@ -4014,11 +3588,41 @@ 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);
}
}
/**
* 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
* @param string $alias Alias
* @return $this
*/
public function setJoin(DataObject $object, $alias = null) {
$this->joinRecord = $object;
if ($alias) {
if (static::getSchema()->fieldSpec(static::class, $alias)) {
throw new InvalidArgumentException(
"Joined record $alias cannot also be a db field"
);
}
$this->record[$alias] = $object;
}
return $this;
}
}

View File

@ -2,6 +2,7 @@
namespace SilverStripe\ORM;
use Exception;
use SilverStripe\Core\Injector\Injectable;
use SilverStripe\Core\Config\Configurable;
use SilverStripe\ORM\FieldType\DBComposite;
@ -118,7 +119,7 @@ class DataObjectSchema {
$class = ClassInfo::class_name($class);
$current = $class;
while ($next = get_parent_class($current)) {
if ($next === 'SilverStripe\ORM\DataObject') {
if ($next === DataObject::class) {
return $current;
}
$current = $next;
@ -136,6 +137,62 @@ class DataObjectSchema {
return $this->tableName($this->baseDataClass($class));
}
/**
* Get all DB field specifications for a class, including ancestors and composite fields.
*
* @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 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) {
// 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;
}
/**
* Get specifications for a single class field
*
* @param string|DataObject $classOrInstance Name or instance of class
* @param string $fieldName
* @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 fieldSpec($classOrInstance, $fieldName, $options = []) {
$specs = $this->fieldSpecs($classOrInstance, $options);
return isset($specs[$fieldName]) ? $specs[$fieldName] : null;
}
/**
* Find the class for the given table
*
@ -169,8 +226,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);
@ -200,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, '\\'));
}
@ -212,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 === 'SilverStripe\ORM\DataObject') {
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);
}
/**
@ -238,7 +328,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);
@ -251,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;
}
/**
@ -269,8 +372,8 @@ class DataObjectSchema {
$dbFields = array();
// Ensure fixed fields appear at the start
$fixedFields = DataObject::config()->fixed_fields;
if(get_parent_class($class) === 'SilverStripe\ORM\DataObject') {
$fixedFields = DataObject::config()->get('fixed_fields');
if(get_parent_class($class) === DataObject::class) {
// Merge fixed with ClassName spec and custom db fields
$dbFields = $fixedFields;
} else {
@ -291,7 +394,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';
@ -308,7 +411,7 @@ class DataObjectSchema {
}
}
// Prevent field-less tables
// Prevent field-less tables with only 'ID'
if(count($dbFields) < 2) {
$dbFields = [];
}
@ -347,19 +450,19 @@ 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;
}
// 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;
}
@ -367,4 +470,456 @@ class DataObjectSchema {
}
return null;
}
/**
* Return information about a specific many_many component. Returns a numeric array.
* The first item in the array will be the class name of the relation: either
* RELATION_MANY_MANY or RELATION_MANY_MANY_THROUGH constant value.
*
* Standard many_many return type is:
*
* array(
* <manyManyClass>, Name of class for relation. E.g. "Categories"
* <classname>, The class that relation is defined in e.g. "Product"
* <candidateName>, The target class of the relation e.g. "Category"
* <parentField>, The field name pointing to <classname>'s table e.g. "ProductID".
* <childField>, The field name pointing to <candidatename>'s table e.g. "CategoryID".
* <joinTableOrRelation> The join table between the two classes e.g. "Product_Categories".
* If the class name is 'ManyManyThroughList' then this is the name of the
* has_many relation.
* )
* @param string $class Name of class to get component for
* @param string $component The component name
* @return array|null
*/
public function manyManyComponent($class, $component) {
$classes = ClassInfo::ancestry($class);
foreach($classes as $parentClass) {
// Check if the component is defined in many_many on this class
$manyMany = Config::inst()->get($parentClass, 'many_many', Config::UNINHERITED);
if(isset($manyMany[$component])) {
return $this->parseManyManyComponent($parentClass, $component, $manyMany[$component]);
}
// Check if the component is defined in belongs_many_many on this class
$belongsManyMany = Config::inst()->get($parentClass, 'belongs_many_many', Config::UNINHERITED);
if (!isset($belongsManyMany[$component])) {
continue;
}
// Extract class and relation name from dot-notation
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->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.
*
* @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 the join class isn't also the name of a field or relation on either side
// of the relation
$field = $this->fieldSpec($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(
"many_many through relation {$parentClass}.{$component} {$key} references a field name "
. "{$joinClass}::{$relation} of type {$relationClass}; {$parentClass} expected"
);
}
return $relationClass;
}
/**
* @param string $parentClass Name of parent class
* @param string $component Name of many_many component
* @param array $specification Complete many_many specification
* @return string Name of join class
*/
protected function checkManyManyJoinClass($parentClass, $component, $specification)
{
if (empty($specification['through'])) {
throw new InvalidArgumentException(
"many_many relation {$parentClass}.{$component} has missing through which should be "
. "a DataObject class name to be used as a join table"
);
}
$joinClass = $specification['through'];
if (!class_exists($joinClass)) {
throw new InvalidArgumentException(
"many_many relation {$parentClass}.{$component} has through class \"{$joinClass}\" which does not exist"
);
}
return $joinClass;
}
/**
* Validate a given class is valid for a relation
*
* @param string $class Parent class
* @param string $component Component name
* @param string $relationClass Candidate class to check
* @param string $type Relation type (e.g. has_one)
*/
protected function checkRelationClass($class, $component, $relationClass, $type)
{
if (!is_string($component) || is_numeric($component)) {
throw new InvalidArgumentException(
"{$class} has invalid {$type} relation name"
);
}
if (!is_string($relationClass)) {
throw new InvalidArgumentException(
"{$type} relation {$class}.{$component} is not a class name"
);
}
if (!class_exists($relationClass)) {
throw new InvalidArgumentException(
"{$type} relation {$class}.{$component} references class {$relationClass} which doesn't exist"
);
}
// Support polymorphic has_one
if ($type === 'has_one') {
$valid = is_a($relationClass, DataObject::class, true);
} else {
$valid = is_subclass_of($relationClass, DataObject::class, true);
}
if (!$valid) {
throw new InvalidArgumentException(
"{$type} relation {$class}.{$component} references class {$relationClass} "
. " which is not a subclass of " . DataObject::class
);
}
}
}

View File

@ -46,6 +46,13 @@ class DataQuery {
*/
protected $collidingFields = array();
/**
* Allows custom callback to be registered before getFinalisedQuery is called.
*
* @var DataQueryManipulator[]
*/
protected $dataQueryManipulators = [];
private $queriedColumns = null;
/**
@ -187,9 +194,14 @@ class DataQuery {
if($queriedColumns) {
$queriedColumns = array_merge($queriedColumns, array('Created', 'LastEdited', 'ClassName'));
}
$query = clone $this->query;
// Apply manipulators before finalising query
foreach($this->getDataQueryManipulators() as $manipulator) {
$manipulator->beforeGetFinalisedQuery($this, $queriedColumns, $query);
}
$schema = DataObject::getSchema();
$query = clone $this->query;
$baseDataClass = $schema->baseDataClass($this->dataClass());
$baseIDColumn = $schema->sqlColumnForField($baseDataClass, 'ID');
$ancestorClasses = ClassInfo::ancestry($this->dataClass(), true);
@ -228,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));
}
@ -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;
}
@ -491,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);
@ -511,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);
@ -710,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");
@ -726,15 +744,16 @@ 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");
}
// 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 +797,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);
@ -817,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");
@ -833,10 +849,8 @@ class DataQuery {
$this->query->addLeftJoin($foreignTable, "{$foreignKeyIDColumn} = {$localIDColumn}");
}
/**
* add join clause to the component's ancestry classes so that the search filter could search on
* its ancestor fields.
*/
// Add join clause to the component's ancestry classes so that the search filter could search on
// its ancestor fields.
$ancestry = ClassInfo::ancestry($foreignClass, true);
$ancestry = array_reverse($ancestry);
foreach($ancestry as $ancestor) {
@ -850,20 +864,27 @@ class DataQuery {
/**
* Join table via many_many relationship
*
* @param string $relationClass
* @param string $parentClass
* @param string $componentClass
* @param string $parentField
* @param string $componentField
* @param string $relationTable Name of relation table
* @param string $relationClassOrTable Name of relation table
*/
protected function joinManyManyRelationship($parentClass, $componentClass, $parentField, $componentField, $relationTable) {
protected function joinManyManyRelationship(
$relationClass, $parentClass, $componentClass, $parentField, $componentField, $relationClassOrTable
) {
$schema = DataObject::getSchema();
if (class_exists($relationClassOrTable)) {
$relationClassOrTable = $schema->tableName($relationClassOrTable);
}
// Join on parent table
$parentIDColumn = $schema->sqlColumnForField($parentClass, 'ID');
$this->query->addLeftJoin(
$relationTable,
"\"$relationTable\".\"$parentField\" = {$parentIDColumn}"
$relationClassOrTable,
"\"$relationClassOrTable\".\"$parentField\" = {$parentIDColumn}"
);
// Join on base table of component class
@ -873,14 +894,12 @@ class DataQuery {
if (!$this->query->isJoinedTo($componentBaseTable)) {
$this->query->addLeftJoin(
$componentBaseTable,
"\"$relationTable\".\"$componentField\" = {$componentIDColumn}"
"\"$relationClassOrTable\".\"$componentField\" = {$componentIDColumn}"
);
}
/**
* add join clause to the component's ancestry classes so that the search filter could search on
* its ancestor fields.
*/
// Add join clause to the component's ancestry classes so that the search filter could search on
// its ancestor fields.
$ancestry = ClassInfo::ancestry($componentClass, true);
$ancestry = array_reverse($ancestry);
foreach($ancestry as $ancestor) {
@ -1017,4 +1036,26 @@ class DataQuery {
public function getQueryParams() {
return $this->queryParams;
}
/**
* Get query manipulators
*
* @return DataQueryManipulator[]
*/
public function getDataQueryManipulators()
{
return $this->dataQueryManipulators;
}
/**
* Assign callback to be invoked in getFinalisedQuery()
*
* @param DataQueryManipulator $manipulator
* @return $this
*/
public function pushQueryManipulator(DataQueryManipulator $manipulator)
{
$this->dataQueryManipulators[] = $manipulator;
return $this;
}
}

View File

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

View File

@ -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 "<li>$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 "<li>$sql [{$id}]</li>";
DB::prepared_query($sql, [$id]);
}
}
}

View File

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

View File

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

View File

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

190
ORM/ManyManyThroughList.php Normal file
View File

@ -0,0 +1,190 @@
<?php
namespace SilverStripe\ORM;
use BadMethodCallException;
use InvalidArgumentException;
use SilverStripe\Core\Injector\Injector;
/**
* ManyManyList backed by a dataobject join table
*/
class ManyManyThroughList extends RelationList
{
/**
* @var ManyManyThroughQueryManipulator
*/
protected $manipulator;
/**
* Create a new ManyManyRelationList object. This relation will utilise an intermediary dataobject
* as a join table, unlike ManyManyList which scaffolds a table automatically.
*
* @param string $dataClass The class of the DataObjects that this will list.
* @param string $joinClass Class name of the joined dataobject record
* @param string $localKey The key in the join table that maps to the dataClass' PK.
* @param string $foreignKey The key in the join table that maps to joined class' PK.
*
* @example new ManyManyThroughList('Banner', 'PageBanner', 'BannerID', 'PageID');
*/
public function __construct($dataClass, $joinClass, $localKey, $foreignKey) {
parent::__construct($dataClass);
// Inject manipulator
$this->manipulator = ManyManyThroughQueryManipulator::create($joinClass, $localKey, $foreignKey);
$this->dataQuery->pushQueryManipulator($this->manipulator);
}
/**
* Don't apply foreign ID filter until getFinalisedQuery()
*
* @param array|integer $id (optional) An ID or an array of IDs - if not provided, will use the current ids as
* per getForeignID
* @return array Condition In array(SQL => parameters format)
*/
protected function foreignIDFilter($id = null) {
// foreignIDFilter is applied to the HasManyList via ManyManyThroughQueryManipulator, not here
return [];
}
public function createDataObject($row) {
// Add joined record
$joinRow = [];
$joinAlias = $this->manipulator->getJoinAlias();
$prefix = $joinAlias . '_';
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, $joinAlias);
}
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");
}
// Apply this item to each given foreign ID record
if(!is_array($foreignIDs)) {
$foreignIDs = [$foreignIDs];
}
$foreignIDsToAdd = array_combine($foreignIDs, $foreignIDs);
// Update existing records
$localKey = $this->manipulator->getLocalKey();
$foreignKey = $this->manipulator->getForeignKey();
$hasManyList = $this->manipulator->getParentRelationship($this->dataQuery());
$records = $hasManyList->filter($localKey, $itemID);
/** @var DataObject $record */
foreach($records as $record) {
if ($extraFields) {
foreach ($extraFields as $field => $value) {
$record->$field = $value;
}
$record->write();
}
//
$foreignID = $record->$foreignKey;
unset($foreignIDsToAdd[$foreignID]);
}
// Once existing records are updated, add missing mapping records
foreach($foreignIDsToAdd as $foreignID) {
$record = $hasManyList->createDataObject($extraFields ?: []);
$record->$foreignKey = $foreignID;
$record->$localKey = $itemID;
$record->write();
}
}
}

View File

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

View File

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

View File

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

View File

@ -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);
@ -1041,7 +1042,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 +1132,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;
}
@ -1393,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);
}
/**
@ -1524,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);
@ -2494,4 +2486,30 @@ class Versioned extends DataExtension implements TemplateGlobalProvider {
public function hasStages() {
return $this->mode === static::STAGEDVERSIONED;
}
/**
* Merge single object into a list
*
* @param ArrayList $list Global list. Object will not be added if already added to this list.
* @param ArrayList $added Additional list to insert into
* @param DataObject $item Item to add
* @return mixed
*/
protected function mergeRelatedObject($list, $added, $item)
{
// Identify item
$itemKey = get_class($item) . '/' . $item->ID;
// Write if saved, versioned, and not already added
if ($item->isInDB() && $item->has_extension('SilverStripe\ORM\Versioning\Versioned') && !isset($list[$itemKey])) {
$list[$itemKey] = $item;
$added[$itemKey] = $item;
}
// Add joined record (from many_many through) automatically
$joined = $item->getJoin();
if ($joined) {
$this->mergeRelatedObject($list, $added, $joined);
}
}
}

View File

@ -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

View File

@ -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) {

View File

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

View File

@ -852,9 +852,13 @@ A very small number of methods were chosen for deprecation, and will be removed
#### <a name="overview-orm-api"></a>ORM API Additions / Changes
* Deprecate `SQLQuery` in favour `SQLSelect`
* `DataList::filter` by null now internally generates "IS NULL" or "IS NOT NULL" conditions appropriately on queries
* `DataObject.many_many` 'through' relationships now support join dataobjects in place of
automatically generated join tables. See the [/developer_guides/relations](datamodel relationship docs)
for more info.
* `DataList::filter` by null now internally generates "IS NULL" or "IS NOT NULL" conditions
appropriately on queries.
* `DataList::createDataObject` is now public.
* `DataObject` constructor now has an additional parameter, which must be included in subclasses.
* `DataObject::database_fields` now returns all fields on that table.
* `DataObject::db` now returns composite fields.
* `DataObject::ClassName` field has been refactored into a `DBClassName` type field.
* `DataObject::can` has new method signature with `$context` parameter.
@ -905,6 +909,24 @@ A very small number of methods were chosen for deprecation, and will be removed
#### <a name="overview-orm-removed"></a>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
@ -913,6 +935,10 @@ A very small number of methods were chosen for deprecation, and will be removed
* Removed `DBString::LimitWordCountXML()` method. Use `LimitWordCount` for XML safe version.
* Removed `SiteTree::getExistsOnLive()`. Use `isPublished()` instead.
* Removed `SiteTree::getIsDeletedFromStage()`. Use `isOnDraft()` instead (inverse case).
* `DataObject.many_many` no longer supports triangular resolution. Both the `many_many` and `belongs_many_many`
must point directly to the specific class on the opposing side, not a subclass or parent.
* `DataObject::validateModelDefinitions()` has been removed. Validation and parsing of config is now handled
within `DataObjectSchema`.
### <a name="overview-filesystem"></a>Filesystem API

View File

@ -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
*/

View File

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

View File

@ -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',

View File

@ -3,13 +3,16 @@
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;
use SilverStripe\View\ViewableData;
/**
* @package framework
@ -58,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);
@ -71,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);
@ -88,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');
@ -784,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'),
@ -835,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');
}
@ -872,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',
@ -889,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',
@ -907,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',
@ -927,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'
);
}
@ -1101,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() {
@ -1186,77 +1202,21 @@ 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);
DataObject::getSchema()->hasOneComponent(DataObjectTest_Team::class, '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);
DataObject::getSchema()->hasManyComponent(DataObjectTest_Team::class, 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);
DataObject::getSchema()->manyManyComponent(DataObjectTest_Team::class, 'Players');
}
public function testNewClassInstance() {
@ -1292,13 +1252,17 @@ 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)
= 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');
$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');
@ -1360,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();
@ -1378,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)'
));
@ -1570,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.'
);
@ -1579,39 +1543,50 @@ 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.'
);
$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');
}

View File

@ -0,0 +1,368 @@
<?php
use SilverStripe\Dev\Debug;
use SilverStripe\Dev\SapphireTest;
use SilverStripe\Dev\TestOnly;
use SilverStripe\ORM\DataObject;
use SilverStripe\ORM\ManyManyThroughList;
use SilverStripe\ORM\Versioning\Versioned;
class ManyManyThroughListTest extends SapphireTest
{
protected static $fixture_file = 'ManyManyThroughListTest.yml';
protected $extraDataObjects = [
ManyManyThroughListTest_Item::class,
ManyManyThroughListTest_JoinObject::class,
ManyManyThroughListTest_Object::class,
ManyManyThroughListTest_VersionedItem::class,
ManyManyThroughListTest_VersionedJoinObject::class,
ManyManyThroughListTest_VersionedObject::class,
];
public function 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');
$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);
$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(['"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() {
/** @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->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() {
/** @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
);
}
/**
* Test validation
*/
public function testValidateModelValidatesJoinType() {
DataObject::reset();
ManyManyThroughListTest_Item::config()->update('db', [
'ManyManyThroughListTest_JoinObject' => 'Text'
]);
$this->setExpectedException(InvalidArgumentException::class);
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')
);
}
}
/**
* 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',
'Sort' => 'Int',
];
private static $has_one = [
'Parent' => ManyManyThroughListTest_Object::class,
'Child' => ManyManyThroughListTest_Item::class,
];
}
/**
* @property string $Title
* @method ManyManyThroughList Objects()
*/
class ManyManyThroughListTest_Item extends DataObject implements TestOnly
{
private static $db = [
'Title' => 'Varchar'
];
private static $belongs_many_many = [
'Objects' => 'ManyManyThroughListTest_Object.Items'
];
}
/**
* Basic parent object
*
* @property string $Title
* @method ManyManyThroughList Items()
* @mixin Versioned
*/
class ManyManyThroughListTest_VersionedObject extends DataObject implements TestOnly
{
private static $db = [
'Title' => 'Varchar'
];
private static $extensions = [
Versioned::class
];
private static $owns = [
'Items' // Should automatically own both mapping and child records
];
private static $many_many = [
'Items' => [
'through' => ManyManyThroughListTest_VersionedJoinObject::class,
'from' => 'Parent',
'to' => 'Child',
]
];
}
/**
* @property string $Title
* @method ManyManyThroughListTest_VersionedObject Parent()
* @method ManyManyThroughListTest_VersionedItem Child()
* @mixin Versioned
*/
class ManyManyThroughListTest_VersionedJoinObject extends DataObject implements TestOnly
{
private static $db = [
'Title' => 'Varchar'
];
private static $extensions = [
Versioned::class
];
private static $has_one = [
'Parent' => ManyManyThroughListTest_VersionedObject::class,
'Child' => ManyManyThroughListTest_VersionedItem::class,
];
}
/**
* @property string $Title
* @method ManyManyThroughList Objects()
* @mixin Versioned
*/
class ManyManyThroughListTest_VersionedItem extends DataObject implements TestOnly
{
private static $db = [
'Title' => 'Varchar'
];
private static $extensions = [
Versioned::class
];
private static $belongs_many_many = [
'Objects' => 'ManyManyThroughListTest_VersionedObject.Items'
];
}

View File

@ -0,0 +1,36 @@
ManyManyThroughListTest_Object:
parent1:
Title: 'my object'
ManyManyThroughListTest_Item:
child1:
Title: 'item 1'
child2:
Title: 'item 2'
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:
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

View File

@ -1,5 +1,6 @@
<?php
use SilverStripe\Dev\Debug;
use SilverStripe\ORM\DB;
use SilverStripe\ORM\HasManyList;
use SilverStripe\ORM\ManyManyList;
@ -405,32 +406,28 @@ class VersionedTest extends SapphireTest {
* Tests DataObject::hasOwnTableDatabaseField
*/
public function testHasOwnTableDatabaseFieldWithVersioned() {
$noversion = new DataObject();
$versioned = new VersionedTest_DataObject();
$versionedSub = new VersionedTest_Subclass();
$versionedAno = new VersionedTest_AnotherSubclass();
$versionField = new VersionedTest_UnversionedWithField();
$schema = DataObject::getSchema();
$this->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.'
);
}