Failed Conditions
Push — task/2976_TYPO3.11_compatibili... ( 14c9f4...2d3a36 )
by Rafael
23:17
created

Relation::render()   A

Complexity

Conditions 4
Paths 6

Size

Total Lines 20
Code Lines 10

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 9
CRAP Score 4.016

Importance

Changes 0
Metric Value
eloc 10
dl 0
loc 20
ccs 9
cts 10
cp 0.9
rs 9.9332
c 0
b 0
f 0
cc 4
nc 6
nop 1
crap 4.016
1
<?php
2
3
namespace ApacheSolrForTypo3\Solr\ContentObject;
4
5
/*
6
 * This file is part of the TYPO3 CMS project.
7
 *
8
 * It is free software; you can redistribute it and/or modify it under
9
 * the terms of the GNU General Public License, either version 2
10
 * of the License, or any later version.
11
 *
12
 * For the full copyright and license information, please read the
13
 * LICENSE.txt file that was distributed with this source code.
14
 *
15
 * The TYPO3 project - inspiring people to share!
16
 */
17
18
use ApacheSolrForTypo3\Solr\System\Language\FrontendOverlayService;
19
use ApacheSolrForTypo3\Solr\System\TCA\TCAService;
20
use Doctrine\DBAL\Exception as DBALException;
21
use Doctrine\DBAL\Driver\Statement;
22
use ReflectionClass;
23
use TYPO3\CMS\Core\Context\Exception\AspectNotFoundException;
24
use TYPO3\CMS\Core\Database\ConnectionPool;
25
use TYPO3\CMS\Core\Database\Query\QueryBuilder;
26
use TYPO3\CMS\Core\Database\RelationHandler;
27
use TYPO3\CMS\Core\Utility\GeneralUtility;
28
use TYPO3\CMS\Frontend\ContentObject\AbstractContentObject;
29
use TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer;
30
use TYPO3\CMS\Frontend\Controller\TypoScriptFrontendController;
31
32
/**
33
 * A content object (cObj) to resolve relations between database records
34
 *
35
 * Configuration options:
36
 *
37
 * localField: the record's field to use to resolve relations
38
 * foreignLabelField: Usually the label field to retrieve from the related records is determined automatically using TCA, using this option the desired field can be specified explicitly
39
 * multiValue: whether to return related records suitable for a multi value field
40
 * singleValueGlue: when not using multiValue, the related records need to be concatenated using a glue string, by default this is ", ". Using this option a custom glue can be specified. The custom value must be wrapped by pipe (|) characters.
41
 * relationTableSortingField: field in a mm relation table to sort by, usually "sorting"
42
 * enableRecursiveValueResolution: if the specified remote table's label field is a relation to another table, the value will be resolved by following the relation recursively.
43
 * removeEmptyValues: Removes empty values when resolving relations, defaults to TRUE
44
 * removeDuplicateValues: Removes duplicate values
45
 *
46
 * @author Ingo Renner <[email protected]>
47
 */
48
class Relation extends AbstractContentObject
49
{
50
    public const CONTENT_OBJECT_NAME = 'SOLR_RELATION';
51
52
    /**
53
     * Content object configuration
54
     *
55
     * @var array
56
     */
57
    protected array $configuration = [];
58
59
    /**
60
     * @var TCAService
61
     */
62
    protected TCAService $tcaService;
63
64
    /**
65
     * @var FrontendOverlayService
66
     */
67
    protected FrontendOverlayService $frontendOverlayService;
68
69
    /**
70
     * @var TypoScriptFrontendController|null
71
     */
72
    protected ?TypoScriptFrontendController $typoScriptFrontendController = null;
73
74
    /**
75
     * Relation constructor.
76
     * @param TCAService|null $tcaService
77
     * @param FrontendOverlayService|null $frontendOverlayService
78
     */
79 13
    public function __construct(
80
        ContentObjectRenderer $cObj,
81
        TCAService $tcaService = null,
82
        FrontendOverlayService $frontendOverlayService = null
83
    ) {
84 13
        parent::__construct($cObj);
85 13
        $this->configuration['enableRecursiveValueResolution'] = 1;
86 13
        $this->configuration['removeEmptyValues'] = 1;
87 13
        $this->tcaService = $tcaService ?? GeneralUtility::makeInstance(TCAService::class);
88 13
        $this->typoScriptFrontendController = $this->getProtectedTsfeObjectFromContentObjectRenderer($cObj);
89 13
        $this->frontendOverlayService = $frontendOverlayService ?? GeneralUtility::makeInstance(FrontendOverlayService::class, $this->tcaService, $this->typoScriptFrontendController);
90 13
    }
91
92
    /**
93
     * Executes the SOLR_RELATION content object.
94
     *
95
     * Resolves relations between records. Currently, supported relations are
96
     * TYPO3-style m:n relations.
97
     * May resolve single value and multi value relations.
98
     *
99
     * @inheritDoc
100
     *
101
     * @throws AspectNotFoundException
102
     */
103 13
    public function render($conf = [])
104
    {
105 13
        $this->configuration = array_merge($this->configuration, $conf);
106
107 13
        $relatedItems = $this->getRelatedItems($this->cObj);
108
109 13
        if (!empty($this->configuration['removeDuplicateValues'])) {
110
            $relatedItems = array_unique($relatedItems);
111
        }
112
113 13
        if (empty($conf['multiValue'])) {
114
            // single value, need to concatenate related items
115 2
            $singleValueGlue = !empty($conf['singleValueGlue']) ? trim($conf['singleValueGlue'], '|') : ', ';
116 2
            $result = implode($singleValueGlue, $relatedItems);
117
        } else {
118
            // multi value, need to serialize as content objects must return strings
119 11
            $result = serialize($relatedItems);
120
        }
121
122 13
        return $result;
123
    }
124
125
    /**
126
     * Gets the related items of the current record's configured field.
127
     *
128
     * @param ContentObjectRenderer $parentContentObject parent content object
129
     *
130
     * @return array Array of related items, values already resolved from related records
131
     *
132
     * @throws AspectNotFoundException
133
     */
134 13
    protected function getRelatedItems(ContentObjectRenderer $parentContentObject): array
135
    {
136 13
        list($table, $uid) = explode(':', $parentContentObject->currentRecord);
137 13
        $uid = (int) $uid;
138 13
        $field = $this->configuration['localField'];
139
140 13
        if (!$this->tcaService->/** @scrutinizer ignore-call */ getHasConfigurationForField($table, $field)) {
141
            return [];
142
        }
143
144 13
        $overlayUid = $this->frontendOverlayService->/** @scrutinizer ignore-call */ getUidOfOverlay($table, $field, $uid);
145 13
        $fieldTCA = $this->tcaService->getConfigurationForField($table, $field);
146
147 13
        if (isset($fieldTCA['config']['MM']) && trim($fieldTCA['config']['MM']) !== '') {
148 10
            $relatedItems = $this->getRelatedItemsFromMMTable($table, $overlayUid, $fieldTCA);
149
        } else {
150 3
            $relatedItems = $this->getRelatedItemsFromForeignTable($table, $overlayUid, $fieldTCA, $parentContentObject);
151
        }
152
153 13
        return $relatedItems;
154
    }
155
156
    /**
157
     * Gets the related items from a table using the n:m relation.
158
     *
159
     * @param string $localTableName Local table name
160
     * @param int $localRecordUid Local record uid
161
     * @param array $localFieldTca The local table's TCA
162
     * @return array Array of related items, values already resolved from related records
163
     *
164
     * @throws AspectNotFoundException
165
     * @throws DBALException
166
     */
167 10
    protected function getRelatedItemsFromMMTable(string $localTableName, int $localRecordUid, array $localFieldTca): array
168
    {
169 10
        $relatedItems = [];
170 10
        $foreignTableName = $localFieldTca['config']['foreign_table'];
171 10
        $foreignTableTca = $this->tcaService->getTableConfiguration($foreignTableName);
172 10
        $foreignTableLabelField = $this->resolveForeignTableLabelField($foreignTableTca);
173 10
        $mmTableName = $localFieldTca['config']['MM'];
174
175
        // Remove the first option of foreignLabelField for recursion
176 10
        if (strpos($this->configuration['foreignLabelField'], '.') !== false) {
177
            $foreignTableLabelFieldArr = explode('.', $this->configuration['foreignLabelField']);
178
            unset($foreignTableLabelFieldArr[0]);
179
            $this->configuration['foreignLabelField'] = implode('.', $foreignTableLabelFieldArr);
180
        }
181
182 10
        $relationHandler = GeneralUtility::makeInstance(RelationHandler::class);
183 10
        $relationHandler->start('', $foreignTableName, $mmTableName, $localRecordUid, $localTableName, $localFieldTca['config']);
184 10
        $selectUids = $relationHandler->tableArray[$foreignTableName];
185 10
        if (!is_array($selectUids) || count($selectUids) <= 0) {
186 2
            return $relatedItems;
187
        }
188
189 10
        $relatedRecords = $this->getRelatedRecords($foreignTableName, ...$selectUids);
190 10
        foreach ($relatedRecords as $record) {
191 10
            if (isset($foreignTableTca['columns'][$foreignTableLabelField]['config']['foreign_table'])
192 10
                && $this->configuration['enableRecursiveValueResolution']
193
            ) {
194
                if (strpos($this->configuration['foreignLabelField'], '.') !== false) {
195
                    $foreignTableLabelFieldArr = explode('.', $this->configuration['foreignLabelField']);
196
                    unset($foreignTableLabelFieldArr[0]);
197
                    $this->configuration['foreignLabelField'] = implode('.', $foreignTableLabelFieldArr);
198
                }
199
200
                $this->configuration['localField'] = $foreignTableLabelField;
201
202
                $contentObject = GeneralUtility::makeInstance(ContentObjectRenderer::class);
203
                $contentObject->start($record, $foreignTableName);
204
205
                return $this->getRelatedItems($contentObject);
206
            } else {
207 10
                if ($this->getLanguageUid() > 0) {
208 7
                    $record = $this->frontendOverlayService->getOverlay($foreignTableName, $record);
209
                }
210 10
                $relatedItems[] = $record[$foreignTableLabelField];
211
            }
212
        }
213
214 10
        return $relatedItems;
215
    }
216
217
    /**
218
     * Resolves the field to use as the related item's label depending on TCA
219
     * and TypoScript configuration
220
     *
221
     * @param array $foreignTableTca The foreign table's TCA
222
     *
223
     * @return string|null The field to use for the related item's label
224
     */
225 13
    protected function resolveForeignTableLabelField(array $foreignTableTca): ?string
226
    {
227 13
        $foreignTableLabelField = $foreignTableTca['ctrl']['label'] ?? null;
228
229
        // when foreignLabelField is not enabled we can return directly
230 13
        if (empty($this->configuration['foreignLabelField'] ?? null)) {
231 11
            return $foreignTableLabelField;
232
        }
233
234 4
        if (strpos($this->configuration['foreignLabelField'], '.') !== false) {
235 2
            list($foreignTableLabelField) = explode('.', $this->configuration['foreignLabelField'], 2);
236
        } else {
237 4
            $foreignTableLabelField = $this->configuration['foreignLabelField'];
238
        }
239
240 4
        return $foreignTableLabelField;
241
    }
242
243
    /**
244
     * Gets the related items from a table using a 1:n relation.
245
     *
246
     * @param string $localTableName Local table name
247
     * @param int $localRecordUid Local record uid
248
     * @param array $localFieldTca The local table's TCA
249
     * @param ContentObjectRenderer $parentContentObject parent content object
250
     * @return array Array of related items, values already resolved from related records
251
     *
252
     * @throws AspectNotFoundException
253
     * @throws DBALException
254
     */
255 3
    protected function getRelatedItemsFromForeignTable(
256
        string                $localTableName,
257
        int                   $localRecordUid,
258
        array                 $localFieldTca,
259
        ContentObjectRenderer $parentContentObject
260
    ): array {
261 3
        $relatedItems = [];
262 3
        $foreignTableName = $localFieldTca['config']['foreign_table'];
263 3
        $foreignTableTca = $this->tcaService->getTableConfiguration($foreignTableName);
264 3
        $foreignTableLabelField = $this->resolveForeignTableLabelField($foreignTableTca);
265
266
        /** @var $relationHandler RelationHandler */
267 3
        $relationHandler = GeneralUtility::makeInstance(RelationHandler::class);
268
269 3
        $itemList = $parentContentObject->data[$this->configuration['localField']] ?? '';
270
271 3
        $relationHandler->start($itemList, $foreignTableName, '', $localRecordUid, $localTableName, $localFieldTca['config']);
272 3
        $selectUids = $relationHandler->tableArray[$foreignTableName];
273
274 3
        if (!is_array($selectUids) || count($selectUids) <= 0) {
275
            return $relatedItems;
276
        }
277
278 3
        $relatedRecords = $this->getRelatedRecords($foreignTableName, ...$selectUids);
279
280 3
        foreach ($relatedRecords as $relatedRecord) {
281 3
            $resolveRelatedValue = $this->resolveRelatedValue(
282 3
                $relatedRecord,
283
                $foreignTableTca,
284
                $foreignTableLabelField,
285
                $parentContentObject,
286
                $foreignTableName
287
            );
288 3
            if (!empty($resolveRelatedValue) || !$this->configuration['removeEmptyValues']) {
289 3
                $relatedItems[] = $resolveRelatedValue;
290
            }
291
        }
292
293 3
        return $relatedItems;
294
    }
295
296
    /**
297
     * Resolves the value of the related field. If the related field's value is
298
     * a relation itself, this method takes care of resolving it recursively.
299
     *
300
     * @param array $relatedRecord Related record as array
301
     * @param array $foreignTableTca TCA of the related table
302
     * @param string $foreignTableLabelField Field name of the foreign label field
303
     * @param ContentObjectRenderer $parentContentObject cObject
304
     * @param string $foreignTableName Related record table name
305
     *
306
     * @return string
307
     *
308
     * @throws AspectNotFoundException
309
     * @throws DBALException
310
     */
311 3
    protected function resolveRelatedValue(
312
        array $relatedRecord,
313
        $foreignTableTca,
314
        $foreignTableLabelField,
315
        ContentObjectRenderer $parentContentObject,
316
        $foreignTableName = ''
317
    ): string {
318 3
        if ($this->getLanguageUid() > 0 && !empty($foreignTableName)) {
319 3
            $relatedRecord = $this->frontendOverlayService->getOverlay($foreignTableName, $relatedRecord);
320
        }
321
322 3
        $value = $relatedRecord[$foreignTableLabelField];
323
324
        if (
325 3
            !empty($foreignTableName)
326 3
            && isset($foreignTableTca['columns'][$foreignTableLabelField]['config']['foreign_table'])
327 3
            && $this->configuration['enableRecursiveValueResolution']
328
        ) {
329
            // backup
330 2
            $backupRecord = $parentContentObject->data;
331 2
            $backupConfiguration = $this->configuration;
332
333
            // adjust configuration for next level
334 2
            $this->configuration['localField'] = $foreignTableLabelField;
335 2
            $parentContentObject->data = $relatedRecord;
336 2
            if (strpos($this->configuration['foreignLabelField'], '.') !== false) {
337 2
                list(, $this->configuration['foreignLabelField']) = explode(
338 2
                    '.',
339 2
                    $this->configuration['foreignLabelField'],
340 2
                    2
341
                );
342
            } else {
343 2
                $this->configuration['foreignLabelField'] = '';
344
            }
345
346
            // recursion
347 2
            $relatedItemsFromForeignTable = $this->getRelatedItemsFromForeignTable(
348 2
                $foreignTableName,
349 2
                $relatedRecord['uid'],
350 2
                $foreignTableTca['columns'][$foreignTableLabelField],
351
                $parentContentObject
352
            );
353 2
            $value = array_pop($relatedItemsFromForeignTable);
354
355
            // restore
356 2
            $this->configuration = $backupConfiguration;
357 2
            $parentContentObject->data = $backupRecord;
358
        }
359
360 3
        return $parentContentObject->stdWrap($value, $this->configuration) ?? '';
361
    }
362
363
    /**
364
     * Return records via relation.
365
     *
366
     * @param string $foreignTable The table to fetch records from.
367
     * @param int ...$uids The uids to fetch from table.
368
     * @return array
369
     *
370
     * @throws DBALException
371
     */
372 13
    protected function getRelatedRecords(string $foreignTable, int ...$uids): array
373
    {
374
        /** @var QueryBuilder $queryBuilder */
375 13
        $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable($foreignTable);
376 13
        $queryBuilder->select('*')
377 13
            ->from($foreignTable)
378 13
            ->where(/** @scrutinizer ignore-type */ $queryBuilder->expr()->in('uid', $uids));
379 13
        if (isset($this->configuration['additionalWhereClause'])) {
380 2
            $queryBuilder->andWhere($this->configuration['additionalWhereClause']);
381
        }
382 13
        $statement = $queryBuilder->execute();
383
384 13
        return $this->sortByKeyInIN($statement, 'uid', ...$uids);
385
    }
386
387
    /**
388
     * Sorts the result set by key in array for IN values.
389
     *   Simulates MySqls ORDER BY FIELD(fieldname, COPY_OF_IN_FOR_WHERE)
390
     *   Example: SELECT * FROM a_table WHERE field_name IN (2, 3, 4) SORT BY FIELD(field_name, 2, 3, 4)
391
     *
392
     *
393
     * @param Statement $statement
394
     * @param string $columnName
395
     * @param array $arrayWithValuesForIN
396
     * @return array
397
     */
398 13
    protected function sortByKeyInIN(Statement $statement, string $columnName, ...$arrayWithValuesForIN): array
399
    {
400 13
        $records = [];
401 13
        while ($record = $statement->fetchAssociative()) {
402 13
            $indexNumber = array_search($record[$columnName], $arrayWithValuesForIN);
403 13
            $records[$indexNumber] = $record;
404
        }
405 13
        ksort($records);
406 13
        return $records;
407
    }
408
409
    /**
410
     * Returns current language id fetched from object properties chain TSFE->context->language aspect->id.
411
     *
412
     * @return int
413
     * @throws AspectNotFoundException
414
     */
415 13
    protected function getLanguageUid(): int
416
    {
417 13
        return (int)$this->typoScriptFrontendController->/** @scrutinizer ignore-call */ getContext()->getPropertyFromAspect('language', 'id');
418
    }
419
420
    /**
421
     * Returns inaccessible {@link ContentObjectRenderer::typoScriptFrontendController} via reflection.
422
     *
423
     * The TypoScriptFrontendController object must be delegated to the whole object aggregation on indexing stack,
424
     * to be able to use Contexts properties and proceed indexing request.
425
     *
426
     * @param ContentObjectRenderer $cObj
427
     * @return TypoScriptFrontendController|null
428
     */
429 13
    protected function getProtectedTsfeObjectFromContentObjectRenderer(ContentObjectRenderer $cObj): ?TypoScriptFrontendController
430
    {
431 13
        $reflection = new ReflectionClass($cObj);
432 13
        $property = $reflection->getProperty('typoScriptFrontendController');
433 13
        $property->setAccessible(true);
434 13
        return $property->getValue($cObj);
435
    }
436
}
437