Passed
Pull Request — task/3376-TYPO3_12_compatibili... (#3530)
by Rafael
42:37
created

Relation::sortByKeyInIN()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 9
Code Lines 6

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
eloc 6
dl 0
loc 9
rs 10
c 0
b 0
f 0
cc 2
nc 2
nop 3
1
<?php
2
3
/*
4
 * This file is part of the TYPO3 CMS project.
5
 *
6
 * It is free software; you can redistribute it and/or modify it under
7
 * the terms of the GNU General Public License, either version 2
8
 * of the License, or any later version.
9
 *
10
 * For the full copyright and license information, please read the
11
 * LICENSE.txt file that was distributed with this source code.
12
 *
13
 * The TYPO3 project - inspiring people to share!
14
 */
15
16
namespace ApacheSolrForTypo3\Solr\ContentObject;
17
18
use ApacheSolrForTypo3\Solr\System\Language\FrontendOverlayService;
19
use ApacheSolrForTypo3\Solr\System\TCA\TCAService;
20
use Doctrine\DBAL\Driver\Exception as DBALDriverException;
21
use Doctrine\DBAL\Exception as DBALException;
22
use Doctrine\DBAL\Result;
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\ContentObject\Exception\ContentRenderingException;
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 FrontendOverlayService|null
61
     */
62
    protected ?FrontendOverlayService $frontendOverlayService = null;
63
64
    /**
65
     * Relation constructor.
66
     *
67
     * @param TCAService $tcaService
68
     */
69
    public function __construct(
70
        protected readonly TCAService $tcaService
71
    ) {
72
        $this->configuration['enableRecursiveValueResolution'] = 1;
73
        $this->configuration['removeEmptyValues'] = 1;
74
    }
75
76
    /**
77
     * Executes the SOLR_RELATION content object.
78
     *
79
     * Resolves relations between records. Currently, supported relations are
80
     * TYPO3-style m:n relations.
81
     * May resolve single value and multi value relations.
82
     *
83
     * @inheritDoc
84
     *
85
     * @param array $conf
86
     * @return string
87
     *
88
     * @throws AspectNotFoundException
89
     * @throws ContentRenderingException
90
     * @throws DBALDriverException
91
     * @throws DBALException
92
     * @noinspection PhpMissingReturnTypeInspection, because foreign source inheritance See {@link AbstractContentObject::render()}
93
     */
94
    public function render($conf = [])
95
    {
96
        $this->configuration = array_merge($this->configuration, $conf);
97
98
        $relatedItems = $this->getRelatedItems($this->cObj);
0 ignored issues
show
Bug introduced by
It seems like $this->cObj can also be of type null; however, parameter $parentContentObject of ApacheSolrForTypo3\Solr\...tion::getRelatedItems() does only seem to accept TYPO3\CMS\Frontend\Conte...t\ContentObjectRenderer, maybe add an additional type check? ( Ignorable by Annotation )

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

98
        $relatedItems = $this->getRelatedItems(/** @scrutinizer ignore-type */ $this->cObj);
Loading history...
99
100
        if (!empty($this->configuration['removeDuplicateValues'])) {
101
            $relatedItems = array_unique($relatedItems);
102
        }
103
104
        if (empty($conf['multiValue'])) {
105
            // single value, need to concatenate related items
106
            $singleValueGlue = !empty($conf['singleValueGlue']) ? trim($conf['singleValueGlue'], '|') : ', ';
107
            $result = implode($singleValueGlue, $relatedItems);
108
        } else {
109
            // multi value, need to serialize as content objects must return strings
110
            $result = serialize($relatedItems);
111
        }
112
113
        return $result;
114
    }
115
116
    /**
117
     * Gets the related items of the current record's configured field.
118
     *
119
     * @param ContentObjectRenderer $parentContentObject parent content object
120
     *
121
     * @return array Array of related items, values already resolved from related records
122
     *
123
     * @throws AspectNotFoundException
124
     * @throws ContentRenderingException
125
     * @throws DBALDriverException
126
     * @throws DBALException
127
     */
128
    protected function getRelatedItems(ContentObjectRenderer $parentContentObject): array
129
    {
130
        list($table, $uid) = explode(':', $parentContentObject->currentRecord);
131
        $uid = (int)$uid;
132
        $field = $this->configuration['localField'];
133
134
        if (!$this->tcaService->getHasConfigurationForField($table, $field)) {
135
            return [];
136
        }
137
138
        $overlayUid = $this->getFrontendOverlayService()->getUidOfOverlay($table, $field, $uid);
139
        $fieldTCA = $this->tcaService->getConfigurationForField($table, $field);
140
141
        if (isset($fieldTCA['config']['MM']) && trim($fieldTCA['config']['MM']) !== '') {
142
            $relatedItems = $this->getRelatedItemsFromMMTable($table, $overlayUid, $fieldTCA);
143
        } else {
144
            $relatedItems = $this->getRelatedItemsFromForeignTable($table, $overlayUid, $fieldTCA, $parentContentObject);
145
        }
146
147
        return $relatedItems;
148
    }
149
150
    /**
151
     * Gets the related items from a table using the n:m relation.
152
     *
153
     * @param string $localTableName Local table name
154
     * @param int $localRecordUid Local record uid
155
     * @param array $localFieldTca The local table's TCA
156
     * @return array Array of related items, values already resolved from related records
157
     *
158
     * @throws AspectNotFoundException
159
     * @throws ContentRenderingException
160
     * @throws DBALDriverException
161
     * @throws DBALException
162
     */
163
    protected function getRelatedItemsFromMMTable(string $localTableName, int $localRecordUid, array $localFieldTca): array
164
    {
165
        $relatedItems = [];
166
        $foreignTableName = $localFieldTca['config']['foreign_table'];
167
        $foreignTableTca = $this->tcaService->getTableConfiguration($foreignTableName);
168
        $foreignTableLabelField = $this->resolveForeignTableLabelField($foreignTableTca);
169
        $mmTableName = $localFieldTca['config']['MM'];
170
171
        // Remove the first option of foreignLabelField for recursion
172
        if (str_contains($this->configuration['foreignLabelField'] ?? '', '.')) {
173
            $foreignTableLabelFieldArr = explode('.', $this->configuration['foreignLabelField']);
174
            unset($foreignTableLabelFieldArr[0]);
175
            $this->configuration['foreignLabelField'] = implode('.', $foreignTableLabelFieldArr);
176
        }
177
178
        $relationHandler = GeneralUtility::makeInstance(RelationHandler::class);
179
        $relationHandler->start('', $foreignTableName, $mmTableName, $localRecordUid, $localTableName, $localFieldTca['config']);
180
        $selectUids = $relationHandler->tableArray[$foreignTableName];
181
        if (!is_array($selectUids) || count($selectUids) <= 0) {
182
            return $relatedItems;
183
        }
184
185
        $contentObject = GeneralUtility::makeInstance(ContentObjectRenderer::class);
186
        $relatedRecords = $this->getRelatedRecords($foreignTableName, ...$selectUids);
187
        foreach ($relatedRecords as $record) {
188
            $contentObject->start($record, $foreignTableName);
189
190
            if (isset($foreignTableTca['columns'][$foreignTableLabelField]['config']['foreign_table'])
191
                && !empty($this->configuration['enableRecursiveValueResolution'])
192
            ) {
193
                $this->configuration['localField'] = $foreignTableLabelField;
194
                if (str_contains($this->configuration['foreignLabelField'], '.')) {
195
                    $foreignTableLabelFieldArr = explode('.', $this->configuration['foreignLabelField']);
196
                    unset($foreignTableLabelFieldArr[0]);
197
                    $this->configuration['foreignLabelField'] = implode('.', $foreignTableLabelFieldArr);
198
                } else {
199
                    unset($this->configuration['foreignLabelField']);
200
                }
201
202
                $relatedItems = array_merge($relatedItems, $this->getRelatedItems($contentObject));
203
                continue;
204
            }
205
            if ($this->getLanguageUid() > 0) {
206
                $record = $this->getFrontendOverlayService()->getOverlay($foreignTableName, $record);
207
            }
208
209
            $relatedItems[] = $contentObject->stdWrap($record[$foreignTableLabelField] ?? '', $this->configuration) ?? '';
210
        }
211
212
        return $relatedItems;
213
    }
214
215
    /**
216
     * Resolves the field to use as the related item's label depending on TCA
217
     * and TypoScript configuration
218
     *
219
     * @param array $foreignTableTca The foreign table's TCA
220
     *
221
     * @return string|null The field to use for the related item's label
222
     */
223
    protected function resolveForeignTableLabelField(array $foreignTableTca): ?string
224
    {
225
        $foreignTableLabelField = $foreignTableTca['ctrl']['label'] ?? null;
226
227
        // when foreignLabelField is not enabled we can return directly
228
        if (empty($this->configuration['foreignLabelField'])) {
229
            return $foreignTableLabelField;
230
        }
231
232
        if (str_contains($this->configuration['foreignLabelField'] ?? '', '.')) {
233
            list($foreignTableLabelField) = explode('.', $this->configuration['foreignLabelField'], 2);
234
        } else {
235
            $foreignTableLabelField = $this->configuration['foreignLabelField'];
236
        }
237
238
        return $foreignTableLabelField;
239
    }
240
241
    /**
242
     * Gets the related items from a table using a 1:n relation.
243
     *
244
     * @param string $localTableName Local table name
245
     * @param int $localRecordUid Local record uid
246
     * @param array $localFieldTca The local table's TCA
247
     * @param ContentObjectRenderer $parentContentObject parent content object
248
     * @return array Array of related items, values already resolved from related records
249
     *
250
     * @throws AspectNotFoundException
251
     * @throws ContentRenderingException
252
     * @throws DBALException
253
     */
254
    protected function getRelatedItemsFromForeignTable(
255
        string $localTableName,
256
        int $localRecordUid,
257
        array $localFieldTca,
258
        ContentObjectRenderer $parentContentObject
259
    ): array {
260
        $relatedItems = [];
261
        $foreignTableName = $localFieldTca['config']['foreign_table'];
262
        $foreignTableTca = $this->tcaService->getTableConfiguration($foreignTableName);
263
        $foreignTableLabelField = $this->resolveForeignTableLabelField($foreignTableTca);
264
        $localField = $this->configuration['localField'];
265
266
        /** @var $relationHandler RelationHandler */
267
        $relationHandler = GeneralUtility::makeInstance(RelationHandler::class);
268
        if (!empty($localFieldTca['config']['MM'] ?? '')) {
269
            $relationHandler->start(
270
                '',
271
                $foreignTableName,
272
                $localFieldTca['config']['MM'],
273
                $localRecordUid,
274
                $localTableName,
275
                $localFieldTca['config']
276
            );
277
        } else {
278
            $itemList = $parentContentObject->data[$localField] ?? '';
279
            $relationHandler->start($itemList, $foreignTableName, '', $localRecordUid, $localTableName, $localFieldTca['config']);
280
        }
281
282
        $selectUids = $relationHandler->tableArray[$foreignTableName];
283
        if (!is_array($selectUids) || count($selectUids) <= 0) {
284
            return $relatedItems;
285
        }
286
287
        $relatedRecords = $this->getRelatedRecords($foreignTableName, ...$selectUids);
288
        foreach ($relatedRecords as $relatedRecord) {
289
            $resolveRelatedValues = $this->resolveRelatedValue(
290
                $relatedRecord,
291
                $foreignTableTca,
292
                $foreignTableLabelField,
0 ignored issues
show
Bug introduced by
It seems like $foreignTableLabelField can also be of type null; however, parameter $foreignTableLabelField of ApacheSolrForTypo3\Solr\...::resolveRelatedValue() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

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

292
                /** @scrutinizer ignore-type */ $foreignTableLabelField,
Loading history...
293
                $parentContentObject,
294
                $foreignTableName
295
            );
296
297
            foreach ($resolveRelatedValues as $resolveRelatedValue) {
298
                if (!empty($resolveRelatedValue) || !$this->configuration['removeEmptyValues']) {
299
                    $relatedItems[] = $resolveRelatedValue;
300
                }
301
            }
302
        }
303
304
        return $relatedItems;
305
    }
306
307
    /**
308
     * Resolves the value of the related field. If the related field's value is
309
     * a relation itself, this method takes care of resolving it recursively.
310
     *
311
     * @param array $relatedRecord Related record as array
312
     * @param array $foreignTableTca TCA of the related table
313
     * @param string $foreignTableLabelField Field name of the foreign label field
314
     * @param ContentObjectRenderer $parentContentObject cObject
315
     * @param string $foreignTableName Related record table name
316
     *
317
     * @return array
318
     *
319
     * @throws AspectNotFoundException
320
     * @throws DBALException
321
     * @throws ContentRenderingException
322
     */
323
    protected function resolveRelatedValue(
324
        array $relatedRecord,
325
        array $foreignTableTca,
326
        string $foreignTableLabelField,
327
        ContentObjectRenderer $parentContentObject,
328
        string $foreignTableName = ''
329
    ): array {
330
        if ($this->getLanguageUid() > 0 && !empty($foreignTableName)) {
331
            $relatedRecord = $this->getFrontendOverlayService()->getOverlay($foreignTableName, $relatedRecord);
332
        }
333
334
        $values = [$relatedRecord[$foreignTableLabelField]];
335
336
        if (
337
            !empty($foreignTableName)
338
            && isset($foreignTableTca['columns'][$foreignTableLabelField]['config']['foreign_table'])
339
            && $this->configuration['enableRecursiveValueResolution']
340
        ) {
341
            // backup
342
            $backupRecord = $parentContentObject->data;
343
            $backupConfiguration = $this->configuration;
344
345
            // adjust configuration for next level
346
            $this->configuration['localField'] = $foreignTableLabelField;
347
            $parentContentObject->data = $relatedRecord;
348
            if (str_contains($this->configuration['foreignLabelField'], '.')) {
349
                list(, $this->configuration['foreignLabelField']) = explode(
350
                    '.',
351
                    $this->configuration['foreignLabelField'],
352
                    2
353
                );
354
            } else {
355
                $this->configuration['foreignLabelField'] = '';
356
            }
357
358
            // recursion
359
            $relatedItemsFromForeignTable = $this->getRelatedItemsFromForeignTable(
360
                $foreignTableName,
361
                $relatedRecord['uid'],
362
                $foreignTableTca['columns'][$foreignTableLabelField],
363
                $parentContentObject
364
            );
365
            $values = $relatedItemsFromForeignTable;
366
367
            // restore
368
            $this->configuration = $backupConfiguration;
369
            $parentContentObject->data = $backupRecord;
370
        }
371
        foreach ($values as &$value) {
372
            $value = $parentContentObject->stdWrap($value, $this->configuration) ?? '';
373
        }
374
375
        return $values;
376
    }
377
378
    /**
379
     * Return records via relation.
380
     *
381
     * @param string $foreignTable The table to fetch records from.
382
     * @param int ...$uids The uids to fetch from table.
383
     * @return array
384
     *
385
     * @throws DBALException
386
     */
387
    protected function getRelatedRecords(string $foreignTable, int ...$uids): array
388
    {
389
        /** @var QueryBuilder $queryBuilder */
390
        $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable($foreignTable);
391
        $queryBuilder->select('*')
392
            ->from($foreignTable)
393
            ->where(/** @scrutinizer ignore-type */ $queryBuilder->expr()->in('uid', $uids));
394
        if (isset($this->configuration['additionalWhereClause'])) {
395
            $queryBuilder->andWhere($this->configuration['additionalWhereClause']);
396
        }
397
        $queryResult = $queryBuilder->executeQuery();
398
399
        return $this->sortByKeyInIN($queryResult, 'uid', ...$uids);
400
    }
401
402
    /**
403
     * Sorts the result set by key in array for IN values.
404
     *   Simulates MySqls ORDER BY FIELD(fieldname, COPY_OF_IN_FOR_WHERE)
405
     *   Example: SELECT * FROM a_table WHERE field_name IN (2, 3, 4) SORT BY FIELD(field_name, 2, 3, 4)
406
     *
407
     *
408
     * @param Result $statement
409
     * @param string $columnName
410
     * @param array $arrayWithValuesForIN
411
     * @return array
412
     * @throws DBALException
413
     */
414
    protected function sortByKeyInIN(Result $statement, string $columnName, ...$arrayWithValuesForIN): array
415
    {
416
        $records = [];
417
        while ($record = $statement->fetchAssociative()) {
418
            $indexNumber = array_search($record[$columnName], $arrayWithValuesForIN);
419
            $records[$indexNumber] = $record;
420
        }
421
        ksort($records);
422
        return $records;
423
    }
424
425
    /**
426
     * Returns current language id fetched from object properties chain TSFE->context->language aspect->id.
427
     *
428
     * @return int
429
     * @throws AspectNotFoundException
430
     * @throws ContentRenderingException
431
     */
432
    protected function getLanguageUid(): int
433
    {
434
        return (int)$this->getTypoScriptFrontendController()
435
            ->getContext()
436
            ->getPropertyFromAspect('language', 'id');
437
    }
438
439
    /**
440
     * Returns and sets FrontendOverlayService instance to this object.
441
     *
442
     * @throws ContentRenderingException
443
     */
444
    protected function getFrontendOverlayService(): FrontendOverlayService
445
    {
446
        if ($this->frontendOverlayService !== null) {
447
            return $this->frontendOverlayService;
448
        }
449
450
        return $this->frontendOverlayService = GeneralUtility::makeInstance(
451
            FrontendOverlayService::class,
452
            $this->tcaService,
453
            $this->getTypoScriptFrontendController()
454
        );
455
    }
456
}
457