API Implement many_many through polymorphic (from only) (#7928)

* API Support many_many through polymorphic relations (from side only)
Fixes #7911
Fixes #3136

* Add extra docs and allow optional arguments

* ENHANCEMENT Enable quiet to be turned off

* BUG Fix issue with manymanythroughlist duplication
This commit is contained in:
Damian Mooyman 2018-03-22 10:26:25 +13:00 committed by Aaron Carlino
parent 1ea14382ee
commit 257ff69e32
16 changed files with 470 additions and 32 deletions

View File

@ -339,6 +339,59 @@ $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.
### Polymorphic many_many (Experimental)
Using many_many through, it is possible to support polymorphic relations on the mapping table.
Note, that this feature is currently experimental, and has certain limitations:
- This feature only works with many_many through
- This feature will only allow polymorphic many_many, but not belongs_many_many. However,
you can have a has_many relation to the mapping table on this side, and iterate through this
to collate parent records.
For instance, this is how you would link an arbitrary object to many_many tags.
```php
use SilverStripe\ORM\DataObject;
class SomeObject extends DataObject
{
// This same many_many may also exist on other classes
private static $many_many = [
"Tags" => [
'through' => TagMapping::class,
'from' => 'Parent',
'to' => 'Tag',
]
];
}
class Tag extends DataObject
{
// has_many works, but belongs_many_many will not
private static $has_many = [
'TagMappings' => TagMapping::class,
];
/**
* Example iterator placeholder for belongs_many_many.
* This is a list of arbitrary types of objects
* @return Generator|DataObject[]
*/
public function TaggedObjects()
{
foreach ($this->TagMappings() as $mapping) {
yield $mapping->Parent();
}
}
}
class TagMapping extends DataObject
{
private static $has_one = [
'Parent' => DataObject::class, // Polymorphic has_one
'Tag' => Tag::class,
];
}
```
### Using many_many in templates

View File

@ -98,10 +98,12 @@ abstract class DBSchemaManager
/**
* Enable supression of database messages.
*
* @param bool $quiet
*/
public function quiet()
public function quiet($quiet = true)
{
$this->supressOutput = true;
$this->supressOutput = $quiet;
}
/**

View File

@ -678,10 +678,12 @@ class DB
/**
* Enable supression of database messages.
*
* @param bool $quiet
*/
public static function quiet()
public static function quiet($quiet = true)
{
self::get_schema()->quiet();
self::get_schema()->quiet($quiet);
}
/**

View File

@ -104,6 +104,7 @@ use stdClass;
* @property string $ClassName Class name of the DataObject
* @property string $LastEdited Date and time of DataObject's last modification.
* @property string $Created Date and time of DataObject creation.
* @property string $ObsoleteClassName If ClassName no longer exists this will be set to the legacy value
*/
class DataObject extends ViewableData implements DataObjectInterface, i18nEntityProvider, Resettable
{
@ -1982,7 +1983,9 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity
$manyMany['join'],
$manyMany['parentField'], // Reversed parent / child field
$manyMany['childField'], // Reversed parent / child field
$extraFields
$extraFields,
$manyMany['childClass'], // substitute child class for parentClass
$remoteClass // In case ManyManyThroughList needs to use PolymorphicHasManyList internally
);
$this->extend('updateManyManyComponents', $result);
@ -2040,10 +2043,11 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity
$manyManyComponent['join'],
$manyManyComponent['childField'],
$manyManyComponent['parentField'],
$extraFields
$extraFields,
$manyManyComponent['parentClass'],
static::class // In case ManyManyThroughList needs to use PolymorphicHasManyList internally
);
// Store component data in query meta-data
$result = $result->alterDataQuery(function ($query) use ($extraFields) {
/** @var DataQuery $query */

View File

@ -940,7 +940,8 @@ class DataObjectSchema
'relationClass' => ManyManyThroughList::class,
'parentClass' => $parentClass,
'childClass' => $joinChildClass,
'parentField' => $specification['from'] . 'ID',
/** @internal Polymorphic many_many is experimental */
'parentField' => $specification['from'] . ($parentClass === DataObject::class ? '' : 'ID'),
'childField' => $specification['to'] . 'ID',
'join' => $joinClass,
];
@ -982,10 +983,18 @@ class DataObjectSchema
if (!$otherManyMany) {
return null;
}
foreach ($otherManyMany as $inverseComponentName => $nextClass) {
if ($nextClass === $parentClass) {
foreach ($otherManyMany as $inverseComponentName => $manyManySpec) {
// Normal many-many
if ($manyManySpec === $parentClass) {
return $inverseComponentName;
}
// many-many through, inspect 'to' for the many_many
if (is_array($manyManySpec)) {
$toClass = $this->hasOneComponent($manyManySpec['through'], $manyManySpec['to']);
if ($toClass === $parentClass) {
return $inverseComponentName;
}
}
}
return null;
}
@ -1106,7 +1115,13 @@ class DataObjectSchema
}
// Check for polymorphic
/** @internal Polymorphic many_many is experimental */
if ($relationClass === DataObject::class) {
// Currently polymorphic 'from' is supported.
if ($key === 'from') {
return $relationClass;
}
// @todo support polymorphic 'to'
throw new InvalidArgumentException(
"many_many through relation {$parentClass}.{$component} {$key} references a polymorphic field "
. "{$joinClass}::{$relation} which is not supported"

View File

@ -4,6 +4,7 @@ namespace SilverStripe\ORM;
use BadMethodCallException;
use InvalidArgumentException;
use SilverStripe\Core\Config\Config;
use SilverStripe\Core\Injector\Injector;
/**
@ -25,14 +26,30 @@ class ManyManyThroughList extends RelationList
* @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.
*
* @param array $extraFields Ignored for ManyManyThroughList
* @param string $foreignClass 'from' class
* @param string $parentClass Parent class (should be subclass of 'from')
* @example new ManyManyThroughList('Banner', 'PageBanner', 'BannerID', 'PageID');
*/
public function __construct($dataClass, $joinClass, $localKey, $foreignKey)
{
public function __construct(
$dataClass,
$joinClass,
$localKey,
$foreignKey,
$extraFields = [],
$foreignClass = null,
$parentClass = null
) {
parent::__construct($dataClass);
// Inject manipulator
$this->manipulator = ManyManyThroughQueryManipulator::create($joinClass, $localKey, $foreignKey);
$this->manipulator = ManyManyThroughQueryManipulator::create(
$joinClass,
$localKey,
$foreignKey,
$foreignClass,
$parentClass
);
$this->dataQuery->pushQueryManipulator($this->manipulator);
}
@ -64,7 +81,7 @@ class ManyManyThroughList extends RelationList
}
// Create parent record
$record = parent::createDataObject($row);
$record = parent::createDataObject($row);
// Create joined record
if ($joinRow) {
@ -145,6 +162,10 @@ class ManyManyThroughList extends RelationList
if (is_numeric($item)) {
$itemID = $item;
} elseif ($item instanceof $this->dataClass) {
/** @var DataObject $item */
if (!$item->isInDB()) {
$item->write();
}
$itemID = $item->ID;
} else {
throw new InvalidArgumentException(
@ -152,7 +173,7 @@ class ManyManyThroughList extends RelationList
);
}
if (empty($itemID)) {
throw new InvalidArgumentException("ManyManyThroughList::add() doesn't accept unsaved records");
throw new InvalidArgumentException("ManyManyThroughList::add() could not add record without ID");
}
// Validate foreignID
@ -169,7 +190,8 @@ class ManyManyThroughList extends RelationList
// Update existing records
$localKey = $this->manipulator->getLocalKey();
$foreignKey = $this->manipulator->getForeignKey();
// Foreign key (or key for ID field if polymorphic)
$foreignKey = $this->manipulator->getForeignIDKey();
$hasManyList = $this->manipulator->getParentRelationship($this->dataQuery());
$records = $hasManyList->filter($localKey, $itemID);
/** @var DataObject $record */
@ -185,12 +207,27 @@ class ManyManyThroughList extends RelationList
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();
// Check if any records remain to add
if (empty($foreignIDsToAdd)) {
return;
}
// Add item to relation
$hasManyList = $hasManyList->forForeignID($foreignIDsToAdd);
$record = $hasManyList->createDataObject($extraFields ?: []);
$record->$localKey = $itemID;
$hasManyList->add($record);
}
/**
* Get extra fields used by this list
*
* @return array a map of field names to types
*/
public function getExtraFields()
{
// Inherit config from join table
$joinClass = $this->manipulator->getJoinClass();
return Config::inst()->get($joinClass, 'db');
}
}

View File

@ -5,6 +5,7 @@ namespace SilverStripe\ORM;
use SilverStripe\Core\Injector\Injectable;
use SilverStripe\Core\Injector\Injector;
use SilverStripe\Dev\Deprecation;
use SilverStripe\ORM\Queries\SQLSelect;
/**
@ -36,6 +37,20 @@ class ManyManyThroughQueryManipulator implements DataQueryManipulator
*/
protected $foreignKey;
/**
* Foreign class 'from' property. Normally not needed unless polymorphic.
*
* @var string
*/
protected $foreignClass;
/**
* Class name of instance that owns this list
*
* @var string
*/
protected $parentClass;
/**
* 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
@ -43,12 +58,24 @@ class ManyManyThroughQueryManipulator implements DataQueryManipulator
* @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.
* @param string $foreignClass the 'from' class name
* @param string $parentClass Name of parent class. Subclass of $foreignClass
*/
public function __construct($joinClass, $localKey, $foreignKey)
public function __construct($joinClass, $localKey, $foreignKey, $foreignClass = null, $parentClass = null)
{
$this->setJoinClass($joinClass);
$this->setLocalKey($localKey);
$this->setForeignKey($foreignKey);
if ($foreignClass) {
$this->setForeignClass($foreignClass);
} else {
Deprecation::notice('5.0', 'Arg $foreignClass will be mandatory in 5.x');
}
if ($parentClass) {
$this->setParentClass($parentClass);
} else {
Deprecation::notice('5.0', 'Arg $parentClass will be mandatory in 5.x');
}
}
/**
@ -95,6 +122,33 @@ class ManyManyThroughQueryManipulator implements DataQueryManipulator
return $this->foreignKey;
}
/**
* Gets ID key name for foreign key component
*
* @return string
*/
public function getForeignIDKey()
{
$key = $this->getForeignKey();
if ($this->getForeignClass() === DataObject::class) {
return $key . 'ID';
}
return $key;
}
/**
* Gets Class key name for foreign key component (or null if none)
*
* @return string|null
*/
public function getForeignClassKey()
{
if ($this->getForeignClass() === DataObject::class) {
return $this->getForeignKey() . 'Class';
}
return null;
}
/**
* @param string $foreignKey
* @return $this
@ -114,11 +168,21 @@ class ManyManyThroughQueryManipulator implements DataQueryManipulator
public function getParentRelationship(DataQuery $query)
{
// Create has_many
$list = HasManyList::create($this->getJoinClass(), $this->getForeignKey());
if ($this->getForeignClass() === DataObject::class) {
/** @internal Polymorphic many_many is experimental */
$list = PolymorphicHasManyList::create(
$this->getJoinClass(),
$this->getForeignKey(),
$this->getParentClass()
);
} else {
$list = HasManyList::create($this->getJoinClass(), $this->getForeignKey());
}
$list = $list->setDataQueryParam($this->extractInheritableQueryParameters($query));
// Limit to given foreign key
if ($foreignID = $query->getQueryParam('Foreign.ID')) {
$foreignID = $query->getQueryParam('Foreign.ID');
if ($foreignID) {
$list = $list->forForeignID($foreignID);
}
return $list;
@ -190,6 +254,8 @@ class ManyManyThroughQueryManipulator implements DataQueryManipulator
}
// Apply join and record sql for later insertion (at end of replacements)
// By using a string placeholder $$_SUBQUERY_$$ we protect field/table rewrites from interfering twice
// on the already-finalised inner list
$sqlSelect->addInnerJoin(
'(SELECT $$_SUBQUERY_$$)',
"\"{$joinTableAlias}\".\"{$localKey}\" = {$childField}",
@ -223,4 +289,40 @@ class ManyManyThroughQueryManipulator implements DataQueryManipulator
$dataQuery->setQueryParam('Foreign.JoinTableSQL', null);
}
}
/**
* @return string
*/
public function getForeignClass()
{
return $this->foreignClass;
}
/**
* @param string $foreignClass
* @return $this
*/
public function setForeignClass($foreignClass)
{
$this->foreignClass = $foreignClass;
return $this;
}
/**
* @return string
*/
public function getParentClass()
{
return $this->parentClass;
}
/**
* @param string $parentClass
* @return ManyManyThroughQueryManipulator
*/
public function setParentClass($parentClass)
{
$this->parentClass = $parentClass;
return $this;
}
}

View File

@ -67,9 +67,8 @@ class PolymorphicHasManyList extends HasManyList
if (is_numeric($item)) {
$item = DataObject::get_by_id($this->dataClass, $item);
} elseif (!($item instanceof $this->dataClass)) {
user_error(
"PolymorphicHasManyList::add() expecting a $this->dataClass object, or ID value",
E_USER_ERROR
throw new InvalidArgumentException(
"PolymorphicHasManyList::add() expecting a $this->dataClass object, or ID value"
);
}

View File

@ -5,7 +5,7 @@ namespace SilverStripe\ORM\Tests;
use SilverStripe\Dev\SapphireTest;
use SilverStripe\ORM\DataObject;
use SilverStripe\ORM\ManyManyThroughList;
use InvalidArgumentException;
use SilverStripe\ORM\Tests\ManyManyThroughListTest\PolyItem;
class ManyManyThroughListTest extends SapphireTest
{
@ -14,7 +14,11 @@ class ManyManyThroughListTest extends SapphireTest
protected static $extra_dataobjects = [
ManyManyThroughListTest\Item::class,
ManyManyThroughListTest\JoinObject::class,
ManyManyThroughListTest\TestObject::class
ManyManyThroughListTest\TestObject::class,
ManyManyThroughListTest\PolyItem::class,
ManyManyThroughListTest\PolyJoinObject::class,
ManyManyThroughListTest\PolyObjectA::class,
ManyManyThroughListTest\PolyObjectB::class,
];
protected function setUp()
@ -185,4 +189,76 @@ class ManyManyThroughListTest extends SapphireTest
$schema->manyManyComponent(ManyManyThroughListTest\Item::class, 'Objects')
);
}
/**
* Note: polymorphic many_many support is currently experimental
*/
public function testPolymorphicManyMany()
{
/** @var ManyManyThroughListTest\PolyObjectA $objA1 */
$objA1 = $this->objFromFixture(ManyManyThroughListTest\PolyObjectA::class, 'obja1');
/** @var ManyManyThroughListTest\PolyObjectB $objB1 */
$objB1 = $this->objFromFixture(ManyManyThroughListTest\PolyObjectB::class, 'objb1');
/** @var ManyManyThroughListTest\PolyObjectB $objB2 */
$objB2 = $this->objFromFixture(ManyManyThroughListTest\PolyObjectB::class, 'objb2');
// Test various parent class queries
$this->assertListEquals([
['Title' => 'item 1'],
['Title' => 'item 2'],
], $objA1->Items());
$this->assertListEquals([
['Title' => 'item 2'],
], $objB1->Items());
$this->assertListEquals([
['Title' => 'item 2'],
], $objB2->Items());
// Test adding items
$newItem = new PolyItem();
$newItem->Title = 'New Item';
$objA1->Items()->add($newItem);
$objB2->Items()->add($newItem);
$this->assertListEquals([
['Title' => 'item 1'],
['Title' => 'item 2'],
['Title' => 'New Item'],
], $objA1->Items());
$this->assertListEquals([
['Title' => 'item 2'],
], $objB1->Items());
$this->assertListEquals([
['Title' => 'item 2'],
['Title' => 'New Item'],
], $objB2->Items());
// Test removing items
$item2 = $this->objFromFixture(ManyManyThroughListTest\PolyItem::class, 'child2');
$objA1->Items()->remove($item2);
$objB1->Items()->remove($item2);
$this->assertListEquals([
['Title' => 'item 1'],
['Title' => 'New Item'],
], $objA1->Items());
$this->assertListEquals([], $objB1->Items());
$this->assertListEquals([
['Title' => 'item 2'],
['Title' => 'New Item'],
], $objB2->Items());
// Test set-by-id-list
$objB2->Items()->setByIDList([
$newItem->ID,
$this->idFromFixture(ManyManyThroughListTest\PolyItem::class, 'child1'),
]);
$this->assertListEquals([
['Title' => 'item 1'],
['Title' => 'New Item'],
], $objA1->Items());
$this->assertListEquals([], $objB1->Items());
$this->assertListEquals([
['Title' => 'item 1'],
['Title' => 'New Item'],
], $objB2->Items());
}
}

View File

@ -17,3 +17,37 @@ SilverStripe\ORM\Tests\ManyManyThroughListTest\JoinObject:
Sort: 2
Parent: =>SilverStripe\ORM\Tests\ManyManyThroughListTest\TestObject.parent1
Child: =>SilverStripe\ORM\Tests\ManyManyThroughListTest\Item.child2
SilverStripe\ORM\Tests\ManyManyThroughListTest\PolyObjectA:
obja1:
Title: 'object A1'
SilverStripe\ORM\Tests\ManyManyThroughListTest\PolyObjectB:
objb1:
Title: 'object B1'
objb2:
Title: 'object B2'
SilverStripe\ORM\Tests\ManyManyThroughListTest\PolyItem:
child1:
Title: 'item 1'
child2:
Title: 'item 2'
SilverStripe\ORM\Tests\ManyManyThroughListTest\PolyJoinObject:
join1:
Title: 'join 1'
Sort: 4
Parent: =>SilverStripe\ORM\Tests\ManyManyThroughListTest\PolyObjectA.obja1
Child: =>SilverStripe\ORM\Tests\ManyManyThroughListTest\PolyItem.child1
join2:
Title: 'join 2'
Sort: 2
Parent: =>SilverStripe\ORM\Tests\ManyManyThroughListTest\PolyObjectA.obja1
Child: =>SilverStripe\ORM\Tests\ManyManyThroughListTest\PolyItem.child2
join3:
Title: 'join 3'
Sort: 2
Parent: =>SilverStripe\ORM\Tests\ManyManyThroughListTest\PolyObjectB.objb1
Child: =>SilverStripe\ORM\Tests\ManyManyThroughListTest\PolyItem.child2
join4:
Title: 'join 4'
Sort: 2
Parent: =>SilverStripe\ORM\Tests\ManyManyThroughListTest\PolyObjectB.objb2
Child: =>SilverStripe\ORM\Tests\ManyManyThroughListTest\PolyItem.child2

View File

@ -19,6 +19,7 @@ class Item extends DataObject implements TestOnly
];
private static $belongs_many_many = [
'Objects' => 'SilverStripe\\ORM\\Tests\\ManyManyThroughListTest\\TestObject.Items'
// Intentionally omit parent `.Items` specifier to ensure it's not mandatory
'Objects' => TestObject::class,
];
}

View File

@ -0,0 +1,36 @@
<?php
namespace SilverStripe\ORM\Tests\ManyManyThroughListTest;
use Generator;
use SilverStripe\Dev\TestOnly;
use SilverStripe\ORM\DataObject;
class PolyItem extends DataObject implements TestOnly
{
private static $table_name = 'ManyManyThroughListTest_PolyItem';
private static $db = [
'Title' => 'Varchar'
];
private static $has_many = [
'JoinObject' => PolyJoinObject::class . '.Items',
];
/**
* Placeholder for missing belongs_many_many for polymorphic relation
*
* @todo Make this work for belongs_many_many
* @return Generator|DataObject[]
*/
public function Objects()
{
foreach ($this->JoinObject() as $object) {
$objectParent = $object->Parent();
if ($objectParent && $objectParent->exists()) {
yield $objectParent;
}
}
}
}

View File

@ -0,0 +1,21 @@
<?php
namespace SilverStripe\ORM\Tests\ManyManyThroughListTest;
use SilverStripe\Dev\TestOnly;
use SilverStripe\ORM\DataObject;
class PolyJoinObject extends DataObject implements TestOnly
{
private static $table_name = 'ManyManyThroughListTest_PolyJoinObject';
private static $db = [
'Title' => 'Varchar',
'Sort' => 'Int',
];
private static $has_one = [
'Parent' => DataObject::class, // Polymorphic parent
'Child' => PolyItem::class,
];
}

View File

@ -0,0 +1,28 @@
<?php
namespace SilverStripe\ORM\Tests\ManyManyThroughListTest;
use SilverStripe\Dev\TestOnly;
use SilverStripe\ORM\DataObject;
use SilverStripe\ORM\ManyManyThroughList;
/**
* @property string $Title
* @method ManyManyThroughList Items()
*/
class PolyObjectA extends DataObject implements TestOnly
{
private static $table_name = 'ManyManyThroughListTest_PolyObjectA';
private static $db = [
'Title' => 'Varchar'
];
private static $many_many = [
'Items' => [
'through' => PolyJoinObject::class,
'from' => 'Parent',
'to' => 'Child',
]
];
}

View File

@ -0,0 +1,28 @@
<?php
namespace SilverStripe\ORM\Tests\ManyManyThroughListTest;
use SilverStripe\Dev\TestOnly;
use SilverStripe\ORM\DataObject;
use SilverStripe\ORM\ManyManyThroughList;
/**
* @property string $Title
* @method ManyManyThroughList Items()
*/
class PolyObjectB extends DataObject implements TestOnly
{
private static $table_name = 'ManyManyThroughListTest_PolyObjectB';
private static $db = [
'Title' => 'Varchar'
];
private static $many_many = [
'Items' => [
'through' => PolyJoinObject::class,
'from' => 'Parent',
'to' => 'Child',
]
];
}

View File

@ -10,7 +10,7 @@ use SilverStripe\ORM\ManyManyThroughList;
* Basic parent object
*
* @property string $Title
* @method ManyManyThroughList Items()
* @method ManyManyThroughList Items()
*/
class TestObject extends DataObject implements TestOnly
{