FIX Empty relations don't have extra DB calls with eager-loading (#10886)

This commit is contained in:
Guy Sartorelli 2023-08-04 12:02:42 +12:00 committed by GitHub
parent ae49e134a9
commit 3628cec1f3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
2 changed files with 259 additions and 55 deletions

View File

@ -1007,10 +1007,15 @@ class DataList extends ViewableData implements SS_List, Filterable, Sortable, Li
} }
$belongsToComponent = $schema->belongsToComponent($parentDataClass, $relationName); $belongsToComponent = $schema->belongsToComponent($parentDataClass, $relationName);
if ($belongsToComponent) { if ($belongsToComponent) {
$joinField = $schema->getRemoteJoinField($parentDataClass, $relationName, 'belongs_to', $polymorphic);
return [ return [
$belongsToComponent, $belongsToComponent,
'belongs_to', 'belongs_to',
$schema->getRemoteJoinField($parentDataClass, $relationName, 'belongs_to'), [
'joinField' => $joinField,
'polymorphic' => $polymorphic,
'parentClass' => $parentDataClass
],
]; ];
} }
$hasManyComponent = $schema->hasManyComponent($parentDataClass, $relationName); $hasManyComponent = $schema->hasManyComponent($parentDataClass, $relationName);
@ -1061,7 +1066,6 @@ class DataList extends ViewableData implements SS_List, Filterable, Sortable, Li
foreach ($this->eagerLoadRelationChains as $relationChain) { foreach ($this->eagerLoadRelationChains as $relationChain) {
$parentDataClass = $this->dataClass(); $parentDataClass = $this->dataClass();
$parentIDs = $topLevelIDs; $parentIDs = $topLevelIDs;
$parentRelationName = '';
/** @var Query|array<DataObject|EagerLoadedList> */ /** @var Query|array<DataObject|EagerLoadedList> */
$parentRelationData = $query; $parentRelationData = $query;
$chainToDate = []; $chainToDate = [];
@ -1080,7 +1084,8 @@ class DataList extends ViewableData implements SS_List, Filterable, Sortable, Li
$relationComponent, $relationComponent,
$relationDataClass, $relationDataClass,
implode('.', $chainToDate), implode('.', $chainToDate),
$relationName $relationName,
$relationType
); );
break; break;
case 'belongs_to': case 'belongs_to':
@ -1090,7 +1095,8 @@ class DataList extends ViewableData implements SS_List, Filterable, Sortable, Li
$relationComponent, $relationComponent,
$relationDataClass, $relationDataClass,
implode('.', $chainToDate), implode('.', $chainToDate),
$relationName $relationName,
$relationType
); );
break; break;
case 'has_many': case 'has_many':
@ -1101,7 +1107,7 @@ class DataList extends ViewableData implements SS_List, Filterable, Sortable, Li
$relationDataClass, $relationDataClass,
implode('.', $chainToDate), implode('.', $chainToDate),
$relationName, $relationName,
$parentRelationName $relationType
); );
break; break;
case 'many_many': case 'many_many':
@ -1112,15 +1118,14 @@ class DataList extends ViewableData implements SS_List, Filterable, Sortable, Li
$relationDataClass, $relationDataClass,
implode('.', $chainToDate), implode('.', $chainToDate),
$relationName, $relationName,
$parentRelationName, $parentDataClass,
$parentDataClass $relationType
); );
break; break;
default: default:
throw new LogicException("Unexpected relation type $relationType"); throw new LogicException("Unexpected relation type $relationType");
} }
$parentDataClass = $relationDataClass; $parentDataClass = $relationDataClass;
$parentRelationName = $relationName;
} }
} }
} }
@ -1130,7 +1135,8 @@ class DataList extends ViewableData implements SS_List, Filterable, Sortable, Li
string $hasOneIDField, string $hasOneIDField,
string $relationDataClass, string $relationDataClass,
string $relationChain, string $relationChain,
string $relationName string $relationName,
string $relationType
): array { ): array {
$fetchedIDs = []; $fetchedIDs = [];
$addTo = []; $addTo = [];
@ -1155,7 +1161,7 @@ class DataList extends ViewableData implements SS_List, Filterable, Sortable, Li
$addTo[$hasOneID] = ['ID' => $parentRow['ID'], 'list' => $parentData]; $addTo[$hasOneID] = ['ID' => $parentRow['ID'], 'list' => $parentData];
} }
} else { } else {
throw new LogicException("Invalid parent for eager loading has_one relation $relationName"); throw new LogicException("Invalid parent for eager loading $relationType relation $relationName");
} }
} }
@ -1179,34 +1185,54 @@ class DataList extends ViewableData implements SS_List, Filterable, Sortable, Li
} }
} }
if (!$added) { if (!$added) {
throw new LogicException("Couldn't find parent for record $fetchedID on has_one relation $relationName"); throw new LogicException("Couldn't find parent for record $fetchedID on $relationType relation $relationName");
} }
} }
// NOTE: Unlike the other relation types, we don't have to explicitly fill empty DataObject records
// into the has_one components - DataObject does that for us in getComponent() without any extra
// db calls.
return [$fetchedRecords, $fetchedIDs]; return [$fetchedRecords, $fetchedIDs];
} }
private function fetchEagerLoadBelongsTo( private function fetchEagerLoadBelongsTo(
Query|array $parents, Query|array $parents,
array $parentIDs, array $parentIDs,
string $belongsToIDField, array $component,
string $relationDataClass, string $relationDataClass,
string $relationChain, string $relationChain,
string $relationName string $relationName,
string $relationType
): array { ): array {
$belongsToIDField = $component['joinField'];
// Get ALL of the items for this relation up front, for ALL of the parents // Get ALL of the items for this relation up front, for ALL of the parents
// Fetched as an array to avoid sporadic additional queries when the DataList is looped directly // Fetched as an array to avoid sporadic additional queries when the DataList is looped directly
$fetchedRecords = DataObject::get($relationDataClass)->filter([$belongsToIDField => $parentIDs])->toArray(); $fetchedRecords = DataObject::get($relationDataClass)->filter([$belongsToIDField => $parentIDs])->toArray();
$fetchedIDs = []; $fetchedIDs = [];
$foundParentIDs = [];
// Add fetched record to the correct place // Add fetched record to the correct place
foreach ($fetchedRecords as $fetched) { foreach ($fetchedRecords as $fetched) {
$fetchedIDs[] = $fetched->ID; $fetchedIDs[] = $fetched->ID;
$parentID = $fetched->$belongsToIDField; $parentID = $fetched->$belongsToIDField;
$this->addEagerLoadedDataToParent($parents, $parentID, $relationChain, $relationName, $fetched, 'has_one'); $foundParentIDs[] = $parentID;
$this->addEagerLoadedDataToParent($parents, $parentID, $relationChain, $relationName, $fetched, $relationType);
} }
// Load empty DataObject records into any parents which have no child records
$missingParentIDs = array_diff($parentIDs, $foundParentIDs);
$this->fillEmptyEagerLoadedRelations(
$parents,
$missingParentIDs,
$relationChain,
$relationName,
$relationType,
$relationDataClass,
null,
$component
);
return [$fetchedRecords, $fetchedIDs]; return [$fetchedRecords, $fetchedIDs];
} }
@ -1216,7 +1242,8 @@ class DataList extends ViewableData implements SS_List, Filterable, Sortable, Li
string $hasManyIDField, string $hasManyIDField,
string $relationDataClass, string $relationDataClass,
string $relationChain, string $relationChain,
string $relationName string $relationName,
string $relationType
): array { ): array {
// Get ALL of the items for this relation up front, for ALL of the parents // Get ALL of the items for this relation up front, for ALL of the parents
// Fetched as an array to avoid sporadic additional queries when the DataList is looped directly // Fetched as an array to avoid sporadic additional queries when the DataList is looped directly
@ -1234,12 +1261,24 @@ class DataList extends ViewableData implements SS_List, Filterable, Sortable, Li
// If we haven't created a list yet, create it and add it to the correct parent list/record // If we haven't created a list yet, create it and add it to the correct parent list/record
$eagerLoadedList = EagerLoadedList::create($relationDataClass, HasManyList::class, $parentID); $eagerLoadedList = EagerLoadedList::create($relationDataClass, HasManyList::class, $parentID);
$eagerLoadedLists[$parentID] = $eagerLoadedList; $eagerLoadedLists[$parentID] = $eagerLoadedList;
$this->addEagerLoadedDataToParent($parents, $parentID, $relationChain, $relationName, $eagerLoadedList, 'has_many'); $this->addEagerLoadedDataToParent($parents, $parentID, $relationChain, $relationName, $eagerLoadedList, $relationType);
} }
// Add this row to the list // Add this row to the list
$eagerLoadedList->addRow($row); $eagerLoadedList->addRow($row);
} }
// Load empty EagerLoadedLists into any parents which have no child records
$missingParentIDs = array_diff($parentIDs, array_keys($eagerLoadedLists));
$this->fillEmptyEagerLoadedRelations(
$parents,
$missingParentIDs,
$relationChain,
$relationName,
$relationType,
$relationDataClass,
HasManyList::class
);
return [$eagerLoadedLists, $fetchedIDs]; return [$eagerLoadedLists, $fetchedIDs];
} }
@ -1250,7 +1289,8 @@ class DataList extends ViewableData implements SS_List, Filterable, Sortable, Li
string $relationDataClass, string $relationDataClass,
string $relationChain, string $relationChain,
string $relationName, string $relationName,
string $parentDataClass string $parentDataClass,
string $relationType
): array { ): array {
$parentIDField = $manyManyLastComponent['parentField']; $parentIDField = $manyManyLastComponent['parentField'];
$childIDField = $manyManyLastComponent['childField']; $childIDField = $manyManyLastComponent['childField'];
@ -1288,6 +1328,7 @@ class DataList extends ViewableData implements SS_List, Filterable, Sortable, Li
$extraFields $extraFields
); );
} }
$relationListClass = get_class($relationList);
// Get ALL of the items for this relation up front, for ALL of the parents // Get ALL of the items for this relation up front, for ALL of the parents
$fetchedRows = $relationList->forForeignID($parentIDs)->getFinalisedQuery(); $fetchedRows = $relationList->forForeignID($parentIDs)->getFinalisedQuery();
@ -1307,14 +1348,27 @@ class DataList extends ViewableData implements SS_List, Filterable, Sortable, Li
$eagerLoadedList = $eagerLoadedLists[$parentID]; $eagerLoadedList = $eagerLoadedLists[$parentID];
} else { } else {
// If we haven't created a list yet, create it and add it to the correct parent list/record // If we haven't created a list yet, create it and add it to the correct parent list/record
$eagerLoadedList = EagerLoadedList::create($relationDataClass, get_class($relationList), $parentID, $manyManyLastComponent); $eagerLoadedList = EagerLoadedList::create($relationDataClass, $relationListClass, $parentID, $manyManyLastComponent);
$eagerLoadedLists[$parentID] = $eagerLoadedList; $eagerLoadedLists[$parentID] = $eagerLoadedList;
$this->addEagerLoadedDataToParent($parents, $parentID, $relationChain, $relationName, $eagerLoadedList, 'many_many'); $this->addEagerLoadedDataToParent($parents, $parentID, $relationChain, $relationName, $eagerLoadedList, $relationType);
} }
// Add this row to the list // Add this row to the list
$eagerLoadedList->addRow($relationItem); $eagerLoadedList->addRow($relationItem);
} }
// Load empty EagerLoadedLists into any parents which have no child records
$missingParentIDs = array_diff($parentIDs, array_keys($eagerLoadedLists));
$this->fillEmptyEagerLoadedRelations(
$parents,
$missingParentIDs,
$relationChain,
$relationName,
$relationType,
$relationDataClass,
$relationListClass,
$manyManyLastComponent
);
return [$eagerLoadedLists, $fetchedIDs]; return [$eagerLoadedLists, $fetchedIDs];
} }
@ -1367,6 +1421,46 @@ class DataList extends ViewableData implements SS_List, Filterable, Sortable, Li
} }
} }
private function fillEmptyEagerLoadedRelations(
Query|array $parents,
array $missingParentIDs,
string $relationChain,
string $relationName,
string $relationType,
string $relationDataClass,
?string $relationListClass = null,
?array $component = null
): void {
foreach ($missingParentIDs as $id) {
// Build the empty list or record
switch ($relationType) {
case 'has_one':
$dummyData = Injector::inst()->create($relationDataClass);
break;
case 'belongs_to':
$dummyData = Injector::inst()->create($relationDataClass);
$joinField = $component['joinField'];
if ($component['polymorphic']) {
$dummyData->{$joinField . 'ID'} = $id;
$dummyData->{$joinField . 'Class'} = $component['parentClass'];
} else {
$dummyData->$joinField = $id;
}
break;
case 'has_many':
$dummyData = EagerLoadedList::create($relationDataClass, $relationListClass, $id);
break;
case 'many_many':
$dummyData = EagerLoadedList::create($relationDataClass, $relationListClass, $id, $component);
break;
default:
throw new LogicException("Unexpected relation type $relationType");
}
// Add the empty list or record to this parent
$this->addEagerLoadedDataToParent($parents, $id, $relationChain, $relationName, $dummyData, $relationType);
}
}
/** /**
* Eager load relations for DataObjects in this DataList including nested relations * Eager load relations for DataObjects in this DataList including nested relations
* *

View File

@ -3,6 +3,7 @@
namespace SilverStripe\ORM\Tests; namespace SilverStripe\ORM\Tests;
use InvalidArgumentException; use InvalidArgumentException;
use LogicException;
use SilverStripe\Dev\SapphireTest; use SilverStripe\Dev\SapphireTest;
use SilverStripe\ORM\DataList; use SilverStripe\ORM\DataList;
use SilverStripe\ORM\DataObject; use SilverStripe\ORM\DataObject;
@ -37,9 +38,12 @@ use SilverStripe\ORM\Tests\DataListTest\EagerLoading\MixedManyManyEagerLoadObjec
class DataListEagerLoadingTest extends SapphireTest class DataListEagerLoadingTest extends SapphireTest
{ {
protected $usesDatabase = true; protected $usesDatabase = true;
private const SHOW_QUERIES_RESET = 'SET_TO_THIS_VALUE_WHEN_FINISHED';
private $showQueries = self::SHOW_QUERIES_RESET;
public static function getExtraDataObjects() public static function getExtraDataObjects()
{ {
return [ return [
@ -100,6 +104,51 @@ class DataListEagerLoadingTest extends SapphireTest
} }
} }
/**
* Start counting the number of SELECT database queries being run
*/
private function startCountingSelectQueries(): void
{
if ($this->showQueries !== self::SHOW_QUERIES_RESET) {
throw new LogicException('showQueries wasnt reset, you did something wrong');
}
$this->showQueries = $_REQUEST['showqueries'] ?? null;
// force showqueries on to count the number of SELECT statements via output-buffering
// if this approach turns out to be too brittle later on, switch to what debugbar
// does and use tractorcow/proxy-db which should be installed as a dev-dependency
// https://github.com/lekoala/silverstripe-debugbar/blob/master/code/Collector/DatabaseCollector.php#L79
$_REQUEST['showqueries'] = 1;
ob_start();
echo '__START_ITERATE__';
}
/**
* Stop counting database queries and return the count
*/
private function stopCountingSelectQueries(): int
{
$s = ob_get_clean();
$s = preg_replace('/.*__START_ITERATE__/s', '', $s);
$this->resetShowQueries();
return substr_count($s, ': SELECT');
}
/**
* Reset the "showqueries" request var
*/
private function resetShowQueries(): void
{
if ($this->showQueries === self::SHOW_QUERIES_RESET) {
return;
}
if ($this->showQueries) {
$_REQUEST['showqueries'] = $this->showQueries;
} else {
unset($_REQUEST['showqueries']);
}
$this->showQueries = self::SHOW_QUERIES_RESET;
}
/** /**
* @dataProvider provideEagerLoadRelations * @dataProvider provideEagerLoadRelations
*/ */
@ -615,17 +664,9 @@ class DataListEagerLoadingTest extends SapphireTest
{ {
$results = []; $results = [];
$selectCount = -1; $selectCount = -1;
$showqueries = $_REQUEST['showqueries'] ?? null;
try { try {
// force showqueries on to count the number of SELECT statements via output-buffering $this->startCountingSelectQueries();
// if this approach turns out to be too brittle later on, switch to what debugbar
// does and use tractorcow/proxy-db which should be installed as a dev-dependency
// https://github.com/lekoala/silverstripe-debugbar/blob/master/code/Collector/DatabaseCollector.php#L79
$_REQUEST['showqueries'] = 1;
ob_start();
echo '__START_ITERATE__';
$results = []; $results = [];
$i = 0;
if ($chunks) { if ($chunks) {
$dataList = $dataList->chunkedFetch($chunks); $dataList = $dataList->chunkedFetch($chunks);
} }
@ -695,15 +736,9 @@ class DataListEagerLoadingTest extends SapphireTest
} }
} }
} }
$s = ob_get_clean(); $selectCount = $this->stopCountingSelectQueries();
$s = preg_replace('/.*__START_ITERATE__/s', '', $s);
$selectCount = substr_count($s, ': SELECT');
} finally { } finally {
if ($showqueries) { $this->resetShowQueries();
$_REQUEST['showqueries'] = $showqueries;
} else {
unset($_REQUEST['showqueries']);
}
} }
return [$results, $selectCount]; return [$results, $selectCount];
} }
@ -994,11 +1029,11 @@ class DataListEagerLoadingTest extends SapphireTest
} }
/** /**
* @dataProvider provideEagerLoadingEmptyRelationLists * @dataProvider provideEagerLoadingEmptyRelations
*/ */
public function testEagerLoadingEmptyRelationLists(string $iden, string $eagerLoad): void public function testEagerLoadingEmptyRelations(string $iden, string $eagerLoad): void
{ {
$numBaseRecords = 2; $numBaseRecords = 3;
$numLevel1Records = 2; $numLevel1Records = 2;
$numLevel2Records = 2; $numLevel2Records = 2;
// Similar to createEagerLoadData(), except with less relations and // Similar to createEagerLoadData(), except with less relations and
@ -1008,9 +1043,34 @@ class DataListEagerLoadingTest extends SapphireTest
$obj = new EagerLoadObject(); $obj = new EagerLoadObject();
$obj->Title = "obj $i"; $obj->Title = "obj $i";
$objID = $obj->write(); $objID = $obj->write();
if ($i > 1) {
continue;
}
// has_one - level1
$hasOneObj = new HasOneEagerLoadObject();
$hasOneObj->Title = "hasOneObj $i";
$hasOneObjID = $hasOneObj->write();
$obj->HasOneEagerLoadObjectID = $hasOneObjID;
$obj->write();
// belongs_to - level1
$belongsToObj = new BelongsToEagerLoadObject();
$belongsToObj->EagerLoadObjectID = $objID;
$belongsToObj->Title = "belongsToObj $i";
$belongsToObjID = $belongsToObj->write();
if ($i > 0) { if ($i > 0) {
continue; continue;
} }
// has_one - level2
$hasOneSubObj = new HasOneSubEagerLoadObject();
$hasOneSubObj->Title = "hasOneSubObj $i";
$hasOneSubObjID = $hasOneSubObj->write();
$hasOneObj->HasOneSubEagerLoadObjectID = $hasOneSubObjID;
$hasOneObj->write();
// belongs_to - level2
$belongsToSubObj = new BelongsToSubEagerLoadObject();
$belongsToSubObj->BelongsToEagerLoadObjectID = $belongsToObjID;
$belongsToSubObj->Title = "belongsToSubObj $i";
$belongsToSubObj->write();
// has_many // has_many
for ($j = 0; $j < $numLevel1Records; $j++) { for ($j = 0; $j < $numLevel1Records; $j++) {
$hasManyObj = new HasManyEagerLoadObject(); $hasManyObj = new HasManyEagerLoadObject();
@ -1065,31 +1125,81 @@ class DataListEagerLoadingTest extends SapphireTest
} }
} }
// The actual test starts here - everything above is for creating fixtures
$i = 0; $i = 0;
$relations = explode('.', $eagerLoad); $relations = explode('.', $eagerLoad);
try {
foreach (EagerLoadObject::get()->eagerLoad($eagerLoad) as $parentRecord) { foreach (EagerLoadObject::get()->eagerLoad($eagerLoad) as $parentRecord) {
if ($i === 0) {
$this->startCountingSelectQueries();
}
// Test first level items are handled correctly
$relation = $relations[0]; $relation = $relations[0];
$list = $parentRecord->$relation(); $listOrRecord = $parentRecord->$relation();
if (str_starts_with($iden, 'hasone') || str_starts_with($iden, 'belongsto')) {
$class = str_starts_with($iden, 'hasone') ? HasOneEagerLoadObject::class : BelongsToEagerLoadObject::class;
if ($i > 1) {
$this->assertSame(0, $listOrRecord->ID);
}
$this->assertInstanceOf($class, $listOrRecord);
} else {
// For any record after the first one, there should be nothing in the related list. // For any record after the first one, there should be nothing in the related list.
$this->assertCount($i > 0 ? 0 : 2, $list); // All lists, even empty ones, should be an instance of EagerLoadedList
$this->assertCount($i > 0 ? 0 : 2, $listOrRecord);
$this->assertInstanceOf(EagerLoadedList::class, $listOrRecord);
}
$i++; $i++;
// Test second level items are handled correctly
if (count($relations) > 1) { if (count($relations) > 1) {
$j = 0; $j = 0;
foreach ($list as $relatedRecord) {
$relation = $relations[1]; $relation = $relations[1];
$list2 = $relatedRecord->$relation(); if (str_starts_with($iden, 'hasone') || str_starts_with($iden, 'belongsto')) {
$record2 = $listOrRecord->$relation();
$class = str_starts_with($iden, 'hasone') ? HasOneSubEagerLoadObject::class : BelongsToSubEagerLoadObject::class;
if ($j > 0) {
$this->assertSame(0, $record2->ID);
}
$this->assertInstanceOf($class, $record2);
} else {
// For any record after the first one, there should be nothing in the related list. // For any record after the first one, there should be nothing in the related list.
// All lists, even empty ones, should be an instance of EagerLoadedList
foreach ($listOrRecord as $relatedRecord) {
$list2 = $relatedRecord->$relation();
$this->assertCount($j > 0 ? 0 : 2, $list2); $this->assertCount($j > 0 ? 0 : 2, $list2);
$this->assertInstanceOf(EagerLoadedList::class, $list2);
$j++; $j++;
} }
} }
} }
} }
// No queries should have been run after initiating the loop
$this->assertSame(0, $this->stopCountingSelectQueries());
} finally {
$this->resetShowQueries();
}
}
public function provideEagerLoadingEmptyRelationLists(): array public function provideEagerLoadingEmptyRelations(): array
{ {
return [ return [
[
'hasone-onelevel',
'HasOneEagerLoadObject',
],
[
'hasone-twolevels',
'HasOneEagerLoadObject.HasOneSubEagerLoadObject',
],
[
'belongsto-onelevel',
'BelongsToEagerLoadObject',
],
[
'belongsto-twolevels',
'BelongsToEagerLoadObject.BelongsToSubEagerLoadObject',
],
[ [
'hasmany-onelevel', 'hasmany-onelevel',
'HasManyEagerLoadObjects', 'HasManyEagerLoadObjects',