Failed Conditions
Push — task/2976_TYPO3.11_compatibili... ( e29358...ba5be5 )
by Rafael
24:21
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|null
61
     */
62
    protected ?TCAService $tcaService = null;
63
64
    /**
65
     * @var FrontendOverlayService|null
66
     */
67
    protected ?FrontendOverlayService $frontendOverlayService = null;
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(ContentObjectRenderer $cObj, TCAService $tcaService = null, FrontendOverlayService $frontendOverlayService = null)
80
    {
81 13
        parent::__construct($cObj);
82 13
        $this->configuration['enableRecursiveValueResolution'] = 1;
83 13
        $this->configuration['removeEmptyValues'] = 1;
84 13
        $this->tcaService = $tcaService ?? GeneralUtility::makeInstance(TCAService::class);
85 13
        $this->typoScriptFrontendController = $this->getProtectedTsfeObjectFromContentObjectRenderer($cObj);
86 13
        $this->frontendOverlayService = $frontendOverlayService ?? GeneralUtility::makeInstance(FrontendOverlayService::class, $this->tcaService, $this->typoScriptFrontendController);
87 13
    }
88
89
    /**
90
     * Executes the SOLR_RELATION content object.
91
     *
92
     * Resolves relations between records. Currently, supported relations are
93
     * TYPO3-style m:n relations.
94
     * May resolve single value and multi value relations.
95
     *
96
     * @inheritDoc
97
     *
98
     * @throws AspectNotFoundException
99
     */
100 13
    public function render($conf = [])
101
    {
102 13
        $this->configuration = array_merge($this->configuration, $conf);
103
104 13
        $relatedItems = $this->getRelatedItems($this->cObj);
105
106 13
        if (!empty($this->configuration['removeDuplicateValues'])) {
107
            $relatedItems = array_unique($relatedItems);
108
        }
109
110 13
        if (empty($conf['multiValue'])) {
111
            // single value, need to concatenate related items
112 2
            $singleValueGlue = !empty($conf['singleValueGlue']) ? trim($conf['singleValueGlue'], '|') : ', ';
113 2
            $result = implode($singleValueGlue, $relatedItems);
114
        } else {
115
            // multi value, need to serialize as content objects must return strings
116 11
            $result = serialize($relatedItems);
117
        }
118
119 13
        return $result;
120
    }
121
122
    /**
123
     * Gets the related items of the current record's configured field.
124
     *
125
     * @param ContentObjectRenderer $parentContentObject parent content object
126
     *
127
     * @return array Array of related items, values already resolved from related records
128
     *
129
     * @throws AspectNotFoundException
130
     */
131 13
    protected function getRelatedItems(ContentObjectRenderer $parentContentObject): array
132
    {
133 13
        list($table, $uid) = explode(':', $parentContentObject->currentRecord);
134 13
        $uid = (int) $uid;
135 13
        $field = $this->configuration['localField'];
136
137 13
        if (!$this->tcaService->/** @scrutinizer ignore-call */ getHasConfigurationForField($table, $field)) {
138
            return [];
139
        }
140
141 13
        $overlayUid = $this->frontendOverlayService->/** @scrutinizer ignore-call */ getUidOfOverlay($table, $field, $uid);
142 13
        $fieldTCA = $this->tcaService->getConfigurationForField($table, $field);
0 ignored issues
show
Bug introduced by
The method getConfigurationForField() does not exist on null. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

142
        /** @scrutinizer ignore-call */ 
143
        $fieldTCA = $this->tcaService->getConfigurationForField($table, $field);

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
143
144 13
        if (isset($fieldTCA['config']['MM']) && trim($fieldTCA['config']['MM']) !== '') {
145 10
            $relatedItems = $this->getRelatedItemsFromMMTable($table, $overlayUid, $fieldTCA);
146
        } else {
147 3
            $relatedItems = $this->getRelatedItemsFromForeignTable($table, $overlayUid, $fieldTCA, $parentContentObject);
148
        }
149
150 13
        return $relatedItems;
151
    }
152
153
    /**
154
     * Gets the related items from a table using the n:m relation.
155
     *
156
     * @param string $localTableName Local table name
157
     * @param int $localRecordUid Local record uid
158
     * @param array $localFieldTca The local table's TCA
159
     * @return array Array of related items, values already resolved from related records
160
     *
161
     * @throws AspectNotFoundException
162
     * @throws DBALException
163
     */
164 10
    protected function getRelatedItemsFromMMTable(string $localTableName, int $localRecordUid, array $localFieldTca): array
165
    {
166 10
        $relatedItems = [];
167 10
        $foreignTableName = $localFieldTca['config']['foreign_table'];
168 10
        $foreignTableTca = $this->tcaService->getTableConfiguration($foreignTableName);
169 10
        $foreignTableLabelField = $this->resolveForeignTableLabelField($foreignTableTca);
170 10
        $mmTableName = $localFieldTca['config']['MM'];
171
172
        // Remove the first option of foreignLabelField for recursion
173 10
        if (strpos($this->configuration['foreignLabelField'], '.') !== false) {
174
            $foreignTableLabelFieldArr = explode('.', $this->configuration['foreignLabelField']);
175
            unset($foreignTableLabelFieldArr[0]);
176
            $this->configuration['foreignLabelField'] = implode('.', $foreignTableLabelFieldArr);
177
        }
178
179 10
        $relationHandler = GeneralUtility::makeInstance(RelationHandler::class);
180 10
        $relationHandler->start('', $foreignTableName, $mmTableName, $localRecordUid, $localTableName, $localFieldTca['config']);
181 10
        $selectUids = $relationHandler->tableArray[$foreignTableName];
182 10
        if (!is_array($selectUids) || count($selectUids) <= 0) {
183 2
            return $relatedItems;
184
        }
185
186 10
        $relatedRecords = $this->getRelatedRecords($foreignTableName, ...$selectUids);
187 10
        foreach ($relatedRecords as $record) {
188 10
            if (isset($foreignTableTca['columns'][$foreignTableLabelField]['config']['foreign_table'])
189 10
                && $this->configuration['enableRecursiveValueResolution']
190
            ) {
191
                if (strpos($this->configuration['foreignLabelField'], '.') !== false) {
192
                    $foreignTableLabelFieldArr = explode('.', $this->configuration['foreignLabelField']);
193
                    unset($foreignTableLabelFieldArr[0]);
194
                    $this->configuration['foreignLabelField'] = implode('.', $foreignTableLabelFieldArr);
195
                }
196
197
                $this->configuration['localField'] = $foreignTableLabelField;
198
199
                $contentObject = GeneralUtility::makeInstance(ContentObjectRenderer::class);
200
                $contentObject->start($record, $foreignTableName);
201
202
                return $this->getRelatedItems($contentObject);
203
            } else {
204 10
                if ($this->getLanguageUid() > 0) {
205 7
                    $record = $this->frontendOverlayService->getOverlay($foreignTableName, $record);
0 ignored issues
show
Bug introduced by
The method getOverlay() does not exist on null. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

205
                    /** @scrutinizer ignore-call */ 
206
                    $record = $this->frontendOverlayService->getOverlay($foreignTableName, $record);

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
206
                }
207 10
                $relatedItems[] = $record[$foreignTableLabelField];
208
            }
209
        }
210
211 10
        return $relatedItems;
212
    }
213
214
    /**
215
     * Resolves the field to use as the related item's label depending on TCA
216
     * and TypoScript configuration
217
     *
218
     * @param array $foreignTableTca The foreign table's TCA
219
     *
220
     * @return string|null The field to use for the related item's label
221
     */
222 13
    protected function resolveForeignTableLabelField(array $foreignTableTca): ?string
223
    {
224 13
        $foreignTableLabelField = $foreignTableTca['ctrl']['label'] ?? null;
225
226
        // when foreignLabelField is not enabled we can return directly
227 13
        if (empty($this->configuration['foreignLabelField'] ?? null)) {
228 11
            return $foreignTableLabelField;
229
        }
230
231 4
        if (strpos($this->configuration['foreignLabelField'], '.') !== false) {
232 2
            list($foreignTableLabelField) = explode('.', $this->configuration['foreignLabelField'], 2);
233
        } else {
234 4
            $foreignTableLabelField = $this->configuration['foreignLabelField'];
235
        }
236
237 4
        return $foreignTableLabelField;
238
    }
239
240
    /**
241
     * Gets the related items from a table using a 1:n relation.
242
     *
243
     * @param string $localTableName Local table name
244
     * @param int $localRecordUid Local record uid
245
     * @param array $localFieldTca The local table's TCA
246
     * @param ContentObjectRenderer $parentContentObject parent content object
247
     * @return array Array of related items, values already resolved from related records
248
     *
249
     * @throws AspectNotFoundException
250
     * @throws DBALException
251
     */
252 3
    protected function getRelatedItemsFromForeignTable(
253
        string                $localTableName,
254
        int                   $localRecordUid,
255
        array                 $localFieldTca,
256
        ContentObjectRenderer $parentContentObject
257
    ): array {
258 3
        $relatedItems = [];
259 3
        $foreignTableName = $localFieldTca['config']['foreign_table'];
260 3
        $foreignTableTca = $this->tcaService->getTableConfiguration($foreignTableName);
261 3
        $foreignTableLabelField = $this->resolveForeignTableLabelField($foreignTableTca);
262
263
        /** @var $relationHandler RelationHandler */
264 3
        $relationHandler = GeneralUtility::makeInstance(RelationHandler::class);
265
266 3
        $itemList = $parentContentObject->data[$this->configuration['localField']] ?? '';
267
268 3
        $relationHandler->start($itemList, $foreignTableName, '', $localRecordUid, $localTableName, $localFieldTca['config']);
269 3
        $selectUids = $relationHandler->tableArray[$foreignTableName];
270
271 3
        if (!is_array($selectUids) || count($selectUids) <= 0) {
272
            return $relatedItems;
273
        }
274
275 3
        $relatedRecords = $this->getRelatedRecords($foreignTableName, ...$selectUids);
276
277 3
        foreach ($relatedRecords as $relatedRecord) {
278 3
            $resolveRelatedValue = $this->resolveRelatedValue(
279 3
                $relatedRecord,
280
                $foreignTableTca,
281
                $foreignTableLabelField,
282
                $parentContentObject,
283
                $foreignTableName
284
            );
285 3
            if (!empty($resolveRelatedValue) || !$this->configuration['removeEmptyValues']) {
286 3
                $relatedItems[] = $resolveRelatedValue;
287
            }
288
        }
289
290 3
        return $relatedItems;
291
    }
292
293
    /**
294
     * Resolves the value of the related field. If the related field's value is
295
     * a relation itself, this method takes care of resolving it recursively.
296
     *
297
     * @param array $relatedRecord Related record as array
298
     * @param array $foreignTableTca TCA of the related table
299
     * @param string $foreignTableLabelField Field name of the foreign label field
300
     * @param ContentObjectRenderer $parentContentObject cObject
301
     * @param string $foreignTableName Related record table name
302
     *
303
     * @return string
304
     *
305
     * @throws AspectNotFoundException
306
     * @throws DBALException
307
     */
308 3
    protected function resolveRelatedValue(
309
        array $relatedRecord,
310
        $foreignTableTca,
311
        $foreignTableLabelField,
312
        ContentObjectRenderer $parentContentObject,
313
        $foreignTableName = ''
314
    ): string {
315 3
        if ($this->getLanguageUid() > 0 && !empty($foreignTableName)) {
316 3
            $relatedRecord = $this->frontendOverlayService->getOverlay($foreignTableName, $relatedRecord);
317
        }
318
319 3
        $value = $relatedRecord[$foreignTableLabelField];
320
321
        if (
322 3
            !empty($foreignTableName)
323 3
            && isset($foreignTableTca['columns'][$foreignTableLabelField]['config']['foreign_table'])
324 3
            && $this->configuration['enableRecursiveValueResolution']
325
        ) {
326
            // backup
327 2
            $backupRecord = $parentContentObject->data;
328 2
            $backupConfiguration = $this->configuration;
329
330
            // adjust configuration for next level
331 2
            $this->configuration['localField'] = $foreignTableLabelField;
332 2
            $parentContentObject->data = $relatedRecord;
333 2
            if (strpos($this->configuration['foreignLabelField'], '.') !== false) {
334 2
                list(, $this->configuration['foreignLabelField']) = explode(
335 2
                    '.',
336 2
                    $this->configuration['foreignLabelField'],
337 2
                    2
338
                );
339
            } else {
340 2
                $this->configuration['foreignLabelField'] = '';
341
            }
342
343
            // recursion
344 2
            $relatedItemsFromForeignTable = $this->getRelatedItemsFromForeignTable(
345 2
                $foreignTableName,
346 2
                $relatedRecord['uid'],
347 2
                $foreignTableTca['columns'][$foreignTableLabelField],
348
                $parentContentObject
349
            );
350 2
            $value = array_pop($relatedItemsFromForeignTable);
351
352
            // restore
353 2
            $this->configuration = $backupConfiguration;
354 2
            $parentContentObject->data = $backupRecord;
355
        }
356
357 3
        return $parentContentObject->stdWrap($value, $this->configuration) ?? '';
358
    }
359
360
    /**
361
     * Return records via relation.
362
     *
363
     * @param string $foreignTable The table to fetch records from.
364
     * @param int ...$uids The uids to fetch from table.
365
     * @return array
366
     *
367
     * @throws DBALException
368
     */
369 13
    protected function getRelatedRecords(string $foreignTable, int ...$uids): array
370
    {
371
        /** @var QueryBuilder $queryBuilder */
372 13
        $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable($foreignTable);
373 13
        $queryBuilder->select('*')
374 13
            ->from($foreignTable)
375 13
            ->where(/** @scrutinizer ignore-type */ $queryBuilder->expr()->in('uid', $uids));
376 13
        if (isset($this->configuration['additionalWhereClause'])) {
377 2
            $queryBuilder->andWhere($this->configuration['additionalWhereClause']);
378
        }
379 13
        $statement = $queryBuilder->execute();
380
381 13
        return $this->sortByKeyInIN($statement, 'uid', ...$uids);
382
    }
383
384
    /**
385
     * Sorts the result set by key in array for IN values.
386
     *   Simulates MySqls ORDER BY FIELD(fieldname, COPY_OF_IN_FOR_WHERE)
387
     *   Example: SELECT * FROM a_table WHERE field_name IN (2, 3, 4) SORT BY FIELD(field_name, 2, 3, 4)
388
     *
389
     *
390
     * @param Statement $statement
391
     * @param string $columnName
392
     * @param array $arrayWithValuesForIN
393
     * @return array
394
     */
395 13
    protected function sortByKeyInIN(Statement $statement, string $columnName, ...$arrayWithValuesForIN): array
396
    {
397 13
        $records = [];
398 13
        while ($record = $statement->fetchAssociative()) {
399 13
            $indexNumber = array_search($record[$columnName], $arrayWithValuesForIN);
400 13
            $records[$indexNumber] = $record;
401
        }
402 13
        ksort($records);
403 13
        return $records;
404
    }
405
406
    /**
407
     * Returns current language id fetched from object properties chain TSFE->context->language aspect->id.
408
     *
409
     * @return int
410
     * @throws AspectNotFoundException
411
     */
412 13
    protected function getLanguageUid(): int
413
    {
414 13
        return (int)$this->typoScriptFrontendController->/** @scrutinizer ignore-call */ getContext()->getPropertyFromAspect('language', 'id');
415
    }
416
417
    /**
418
     * Returns inaccessible {@link ContentObjectRenderer::typoScriptFrontendController} via reflection.
419
     *
420
     * The TypoScriptFrontendController object must be delegated to the whole object aggregation on indexing stack,
421
     * to be able to use Contexts properties and proceed indexing request.
422
     *
423
     * @param ContentObjectRenderer $cObj
424
     * @return TypoScriptFrontendController|null
425
     */
426 13
    protected function getProtectedTsfeObjectFromContentObjectRenderer(ContentObjectRenderer $cObj): ?TypoScriptFrontendController
427
    {
428 13
        $reflection = new ReflectionClass($cObj);
429 13
        $property = $reflection->getProperty('typoScriptFrontendController');
430 13
        $property->setAccessible(true);
431 13
        return $property->getValue($cObj);
432
    }
433
}
434