Completed
Push — master ( 6207a9...7aae1c )
by Philip
02:19
created

MySQLData::fetchReferencesForField()   B

Complexity

Conditions 6
Paths 10

Size

Total Lines 35
Code Lines 25

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
dl 0
loc 35
rs 8.439
c 0
b 0
f 0
cc 6
eloc 25
nc 10
nop 2
1
<?php
2
3
/*
4
 * This file is part of the CRUDlex package.
5
 *
6
 * (c) Philip Lehmann-Böhm <[email protected]>
7
 *
8
 * For the full copyright and license information, please view the LICENSE
9
 * file that was distributed with this source code.
10
 */
11
12
namespace CRUDlex;
13
14
use Doctrine\DBAL\Connection;
15
use Doctrine\DBAL\Query\QueryBuilder;
16
17
/**
18
 * MySQL Data implementation using a given Doctrine DBAL instance.
19
 */
20
class MySQLData extends AbstractData {
21
22
    /**
23
     * Holds the Doctrine DBAL instance.
24
     */
25
    protected $database;
26
27
    /**
28
     * Flag whether to use UUIDs as primary key.
29
     */
30
    protected $useUUIDs;
31
32
    /**
33
     * Gets the many-to-many fields.
34
     *
35
     * @return array|\string[]
36
     * the many-to-many fields
37
     */
38
    protected function getManyFields() {
39
        $fields = $this->definition->getFieldNames(true);
40
        return array_filter($fields, function($field) {
41
            return $this->definition->getType($field) === 'many';
42
        });
43
    }
44
45
    /**
46
     * Gets all form fields including the many-to-many-ones.
47
     *
48
     * @return array
49
     * all form fields
50
     */
51
    protected function getFormFields() {
52
        $manyFields = $this->getManyFields();
53
        $formFields = [];
54
        foreach ($this->definition->getEditableFieldNames() as $field) {
55
            if (!in_array($field, $manyFields)) {
56
                $formFields[] = $field;
57
            }
58
        }
59
        return $formFields;
60
    }
61
62
    /**
63
     * Sets the values and parameters of the upcoming given query according
64
     * to the entity.
65
     *
66
     * @param Entity $entity
67
     * the entity with its fields and values
68
     * @param QueryBuilder $queryBuilder
69
     * the upcoming query
70
     * @param string $setMethod
71
     * what method to use on the QueryBuilder: 'setValue' or 'set'
72
     */
73
    protected function setValuesAndParameters(Entity $entity, QueryBuilder $queryBuilder, $setMethod) {
74
        $formFields = $this->getFormFields();
75
        $count      = count($formFields);
76
        for ($i = 0; $i < $count; ++$i) {
77
            $type  = $this->definition->getType($formFields[$i]);
78
            $value = $entity->get($formFields[$i]);
79
            if ($type == 'boolean') {
80
                $value = $value ? 1 : 0;
81
            }
82
            if ($type == 'reference' && is_array($value)) {
83
                $value = $value['id'];
84
            }
85
            $queryBuilder->$setMethod('`'.$formFields[$i].'`', '?');
86
            $queryBuilder->setParameter($i, $value);
87
        }
88
    }
89
90
    /**
91
     * Performs the cascading children deletion.
92
     *
93
     * @param integer $id
94
     * the current entities id
95
     * @param boolean $deleteCascade
96
     * whether to delete children and sub children
97
     */
98
    protected function deleteChildren($id, $deleteCascade) {
99
        foreach ($this->definition->getChildren() as $childArray) {
100
            $childData = $this->definition->getServiceProvider()->getData($childArray[2]);
101
            $children  = $childData->listEntries([$childArray[1] => $id]);
102
            foreach ($children as $child) {
103
                $childData->doDelete($child, $deleteCascade);
104
            }
105
        }
106
    }
107
108
    /**
109
     * Checks whether the by id given entity still has children referencing it.
110
     *
111
     * @param integer $id
112
     * the current entities id
113
     *
114
     * @return boolean
115
     * true if the entity still has children
116
     */
117
    protected function hasChildren($id) {
118
        foreach ($this->definition->getChildren() as $child) {
119
            $queryBuilder = $this->database->createQueryBuilder();
120
            $queryBuilder
121
                ->select('COUNT(id)')
122
                ->from('`'.$child[0].'`', '`'.$child[0].'`')
123
                ->where('`'.$child[1].'` = ?')
124
                ->andWhere('deleted_at IS NULL')
125
                ->setParameter(0, $id);
126
            $queryResult = $queryBuilder->execute();
127
            $result      = $queryResult->fetch(\PDO::FETCH_NUM);
128
            if ($result[0] > 0) {
129
                return true;
130
            }
131
        }
132
        return false;
133
    }
134
135
    /**
136
     * {@inheritdoc}
137
     */
138
    protected function doDelete(Entity $entity, $deleteCascade) {
139
        $result = $this->shouldExecuteEvents($entity, 'before', 'delete');
140
        if (!$result) {
141
            return static::DELETION_FAILED_EVENT;
142
        }
143
        $id = $entity->get('id');
144
        if ($deleteCascade) {
145
            $this->deleteChildren($id, $deleteCascade);
146
        } elseif ($this->hasChildren($id)) {
147
            return static::DELETION_FAILED_STILL_REFERENCED;
148
        }
149
150
        $query = $this->database->createQueryBuilder();
151
        $query
152
            ->update('`'.$this->definition->getTable().'`')
153
            ->set('deleted_at', 'UTC_TIMESTAMP()')
154
            ->where('id = ?')
155
            ->setParameter(0, $id);
156
157
        $query->execute();
158
        $this->shouldExecuteEvents($entity, 'after', 'delete');
159
        return static::DELETION_SUCCESS;
160
    }
161
162
    /**
163
     * Gets all possible many-to-many ids existing for this definition.
164
     *
165
     * @param array $fields
166
     * the many field names to fetch for
167
     * @param $params
168
     * the parameters the possible many field values to fetch for
169
     * @return array
170
     * an array of this many-to-many ids
171
     */
172
    protected function getManyIds(array $fields, array $params) {
173
        $manyIds = [];
174
        foreach ($fields as $field) {
175
            $thisField    = $this->definition->getSubTypeField($field, 'many', 'thisField');
176
            $thatField    = $this->definition->getSubTypeField($field, 'many', 'thatField');
177
            $queryBuilder = $this->database->createQueryBuilder();
178
            $queryBuilder
179
                ->select('`'.$thisField.'`')
180
                ->from($field)
181
                ->where('`'.$thatField.'` IN (?)')
182
                ->setParameter(0, array_column($params[$field], 'id'), Connection::PARAM_STR_ARRAY)
183
                ->groupBy('`'.$thisField.'`')
184
            ;
185
            $queryResult = $queryBuilder->execute();
186
            $manyResults = $queryResult->fetchAll(\PDO::FETCH_ASSOC);
187
            $manyIds     = array_merge($manyIds, array_column($manyResults, $thisField));
188
189
        }
190
        return $manyIds;
191
    }
192
193
    /**
194
     * Adds sorting parameters to the query.
195
     *
196
     * @param QueryBuilder $queryBuilder
197
     * the query
198
     * @param $filter
199
     * the filter all resulting entities must fulfill, the keys as field names
200
     * @param $filterOperators
201
     * the operators of the filter like "=" defining the full condition of the field
202
     */
203
    protected function addFilter(QueryBuilder $queryBuilder, array $filter, array $filterOperators) {
204
        $i          = 0;
205
        $manyFields = [];
206
        foreach ($filter as $field => $value) {
207
            if ($this->definition->getType($field) === 'many') {
208
                $manyFields[] = $field;
209
                continue;
210
            }
211
            if ($value === null) {
212
                $queryBuilder->andWhere('`'.$field.'` IS NULL');
213
            } else {
214
                $operator = array_key_exists($field, $filterOperators) ? $filterOperators[$field] : '=';
215
                $queryBuilder
216
                    ->andWhere('`'.$field.'` '.$operator.' ?')
217
                    ->setParameter($i, $value, \PDO::PARAM_STR);
218
            }
219
            $i++;
220
        }
221
        $idsToInclude = $this->getManyIds($manyFields, $filter);
222
        if (!empty($idsToInclude)) {
223
            $queryBuilder
224
                ->andWhere('id IN (?)')
225
                ->setParameter($i, $idsToInclude, Connection::PARAM_STR_ARRAY)
226
            ;
227
        }
228
    }
229
230
    /**
231
     * Adds pagination parameters to the query.
232
     *
233
     * @param QueryBuilder $queryBuilder
234
     * the query
235
     * @param integer|null $skip
236
     * the rows to skip
237
     * @param integer|null $amount
238
     * the maximum amount of rows
239
     */
240
    protected function addPagination(QueryBuilder $queryBuilder, $skip, $amount) {
241
        $queryBuilder->setMaxResults(9999999999);
242
        if ($amount !== null) {
243
            $queryBuilder->setMaxResults(abs(intval($amount)));
244
        }
245
        if ($skip !== null) {
246
            $queryBuilder->setFirstResult(abs(intval($skip)));
247
        }
248
    }
249
250
    /**
251
     * Adds sorting parameters to the query.
252
     *
253
     * @param QueryBuilder $queryBuilder
254
     * the query
255
     * @param string|null $sortField
256
     * the sort field
257
     * @param boolean|null $sortAscending
258
     * true if sort ascending, false if descending
259
     */
260
    protected function addSort(QueryBuilder $queryBuilder, $sortField, $sortAscending) {
261
        if ($sortField !== null) {
262
263
            $type = $this->definition->getType($sortField);
264
            if ($type === 'many') {
265
                $sortField = $this->definition->getInitialSortField();
266
            }
267
268
            $order = $sortAscending === true ? 'ASC' : 'DESC';
269
            $queryBuilder->orderBy('`'.$sortField.'`', $order);
270
        }
271
    }
272
273
274
    /**
275
     * Gets an array of reference ids for the given entities.
276
     *
277
     * @param array $entities
278
     * the entities to extract the ids
279
     * @param $field
280
     * the reference field
281
     *
282
     * @return array
283
     * the extracted ids
284
     */
285
    protected function getReferenceIds(array $entities, $field) {
286
        $ids = array_map(function (Entity $entity) use ($field) {
287
            $id = $entity->get($field);
288
            return is_array($id) ? $id['id'] : $id;
289
        }, $entities);
290
        return $ids;
291
    }
292
293
    /**
294
     * Adds the id and name of referenced entities to the given entities. The
295
     * reference field is before the raw id of the referenced entity and after
296
     * the fetch, it's an array with the keys id and name.
297
     *
298
     * @param Entity[] &$entities
299
     * the entities to fetch the references for
300
     * @param string $field
301
     * the reference field
302
     */
303
    protected function fetchReferencesForField(array &$entities, $field) {
304
        $nameField    = $this->definition->getSubTypeField($field, 'reference', 'nameField');
305
        $queryBuilder = $this->database->createQueryBuilder();
306
307
        $ids = $this->getReferenceIds($entities, $field);
308
309
        $referenceEntity = $this->definition->getSubTypeField($field, 'reference', 'entity');
310
        $table           = $this->definition->getServiceProvider()->getData($referenceEntity)->getDefinition()->getTable();
311
        $queryBuilder
312
            ->from('`'.$table.'`', '`'.$table.'`')
313
            ->where('id IN (?)')
314
            ->andWhere('deleted_at IS NULL');
315
        if ($nameField) {
316
            $queryBuilder->select('id', $nameField);
317
        } else {
318
            $queryBuilder->select('id');
319
        }
320
321
        $queryBuilder->setParameter(0, $ids, Connection::PARAM_STR_ARRAY);
322
323
        $queryResult = $queryBuilder->execute();
324
        $rows        = $queryResult->fetchAll(\PDO::FETCH_ASSOC);
325
        $amount      = count($entities);
326
        foreach ($rows as $row) {
327
            for ($i = 0; $i < $amount; ++$i) {
328
                if ($entities[$i]->get($field) == $row['id']) {
329
                    $value = ['id' => $entities[$i]->get($field)];
330
                    if ($nameField) {
331
                        $value['name'] = $row[$nameField];
332
                    }
333
                    $entities[$i]->set($field, $value);
334
                }
335
            }
336
        }
337
    }
338
339
    /**
340
     * Generates a new UUID.
341
     *
342
     * @return string|null
343
     * the new UUID or null if this instance isn't configured to do so
344
     */
345
    protected function generateUUID() {
346
        $uuid = null;
347
        if ($this->useUUIDs) {
348
            $sql    = 'SELECT UUID() as id';
349
            $result = $this->database->fetchAssoc($sql);
350
            $uuid   = $result['id'];
351
        }
352
        return $uuid;
353
    }
354
355
    /**
356
     * Enriches the given mapping of entity id to raw entity data with some many-to-many data.
357
     *
358
     * @param array $idToData
359
     * a reference to the map entity id to raw entity data
360
     * @param $manyField
361
     * the many field to enrich data with
362
     */
363
    protected function enrichWithManyField(&$idToData, $manyField) {
364
        $queryBuilder = $this->database->createQueryBuilder();
365
        $nameField    = $this->definition->getSubTypeField($manyField, 'many', 'nameField');
366
        $thisField    = $this->definition->getSubTypeField($manyField, 'many', 'thisField');
367
        $thatField    = $this->definition->getSubTypeField($manyField, 'many', 'thatField');
368
        $entity       = $this->definition->getSubTypeField($manyField, 'many', 'entity');
369
        $entityTable  = $this->definition->getServiceProvider()->getData($entity)->getDefinition()->getTable();
370
        $nameSelect   = $nameField !== null ? ', t2.`'.$nameField.'` AS name' : '';
371
        $queryBuilder
372
            ->select('t1.`'.$thisField.'` AS this, t1.`'.$thatField.'` AS id'.$nameSelect)
373
            ->from('`'.$manyField.'`', 't1')
374
            ->leftJoin('t1', '`'.$entityTable.'`', 't2', 't2.id = t1.`'.$thatField.'`')
375
            ->where('t1.`'.$thisField.'` IN (?)')
376
            ->andWhere('t2.deleted_at IS NULL');
377
        $queryBuilder->setParameter(0, array_keys($idToData), Connection::PARAM_STR_ARRAY);
378
        $queryResult    = $queryBuilder->execute();
379
        $manyReferences = $queryResult->fetchAll(\PDO::FETCH_ASSOC);
380
        foreach ($manyReferences as $manyReference) {
381
            $entityId = $manyReference['this'];
382
            unset($manyReference['this']);
383
            $idToData[$entityId][$manyField][] = $manyReference;
384
        }
385
    }
386
387
    /**
388
     * Fetches to the rows belonging many-to-many entries and adds them to the rows.
389
     *
390
     * @param array $rows
391
     * the rows to enrich
392
     * @return array
393
     * the enriched rows
394
     */
395
    protected function enrichWithMany(array $rows) {
396
        $manyFields = $this->getManyFields();
397
        $idToData   = [];
398
        foreach ($rows as $row) {
399
            foreach ($manyFields as $manyField) {
400
                $row[$manyField] = [];
401
            }
402
            $idToData[$row['id']] = $row;
403
        }
404
        foreach ($manyFields as $manyField) {
405
            $this->enrichWithManyField($idToData, $manyField);
406
        }
407
        return array_values($idToData);
408
    }
409
410
    /**
411
     * First, deletes all to the given entity related many-to-many entries from the DB
412
     * and then writes them again.
413
     *
414
     * @param Entity $entity
415
     * the entity to save the many-to-many entries of
416
     */
417
    protected function saveMany(Entity $entity) {
418
        $manyFields = $this->getManyFields();
419
        $id         = $entity->get('id');
420
        foreach ($manyFields as $manyField) {
421
            $thisField = $this->definition->getSubTypeField($manyField, 'many', 'thisField');
422
            $thatField = $this->definition->getSubTypeField($manyField, 'many', 'thatField');
423
            $this->database->delete($manyField, [$thisField => $id]);
424
            $manyValues = $entity->get($manyField) ?: [];
425
            foreach ($manyValues as $thatId) {
426
                $this->database->insert($manyField, [
427
                    $thisField => $id,
428
                    $thatField => $thatId['id']
429
                ]);
430
            }
431
        }
432
    }
433
434
    /**
435
     * Adds the id and name of referenced entities to the given entities. Each
436
     * reference field is before the raw id of the referenced entity and after
437
     * the fetch, it's an array with the keys id and name.
438
     *
439
     * @param Entity[] &$entities
440
     * the entities to fetch the references for
441
     *
442
     * @return void
443
     */
444
    protected function enrichWithReference(array &$entities) {
445
        if (empty($entities)) {
446
            return;
447
        }
448
        foreach ($this->definition->getFieldNames() as $field) {
449
            if ($this->definition->getType($field) !== 'reference') {
450
                continue;
451
            }
452
            $this->fetchReferencesForField($entities, $field);
453
        }
454
    }
455
456
    /**
457
     * Constructor.
458
     *
459
     * @param EntityDefinition $definition
460
     * the entity definition
461
     * @param FileProcessorInterface $fileProcessor
462
     * the file processor to use
463
     * @param $database
464
     * the Doctrine DBAL instance to use
465
     * @param boolean $useUUIDs
466
     * flag whether to use UUIDs as primary key
467
     */
468
    public function __construct(EntityDefinition $definition, FileProcessorInterface $fileProcessor, $database, $useUUIDs) {
469
        $this->definition    = $definition;
470
        $this->fileProcessor = $fileProcessor;
471
        $this->database      = $database;
472
        $this->useUUIDs      = $useUUIDs;
473
    }
474
475
    /**
476
     * {@inheritdoc}
477
     */
478
    public function get($id) {
479
        $entities = $this->listEntries(['id' => $id]);
480
        if (count($entities) == 0) {
481
            return null;
482
        }
483
        return $entities[0];
484
    }
485
486
    /**
487
     * {@inheritdoc}
488
     */
489
    public function listEntries(array $filter = [], array $filterOperators = [], $skip = null, $amount = null, $sortField = null, $sortAscending = null) {
490
        $fieldNames = $this->definition->getFieldNames();
491
492
        $queryBuilder = $this->database->createQueryBuilder();
493
        $table        = $this->definition->getTable();
494
        $queryBuilder
495
            ->select('`'.implode('`,`', $fieldNames).'`')
496
            ->from('`'.$table.'`', '`'.$table.'`')
497
            ->where('deleted_at IS NULL');
498
499
        $this->addFilter($queryBuilder, $filter, $filterOperators);
500
        $this->addPagination($queryBuilder, $skip, $amount);
501
        $this->addSort($queryBuilder, $sortField, $sortAscending);
502
503
        $queryResult = $queryBuilder->execute();
504
        $rows        = $queryResult->fetchAll(\PDO::FETCH_ASSOC);
505
        $rows        = $this->enrichWithMany($rows);
506
        $entities    = [];
507
        foreach ($rows as $row) {
508
            $entities[] = $this->hydrate($row);
509
        }
510
        $this->enrichWithReference($entities);
511
        return $entities;
512
    }
513
514
    /**
515
     * {@inheritdoc}
516
     */
517
    public function create(Entity $entity) {
518
519
        $result = $this->shouldExecuteEvents($entity, 'before', 'create');
520
        if (!$result) {
521
            return false;
522
        }
523
524
        $queryBuilder = $this->database->createQueryBuilder();
525
        $queryBuilder
526
            ->insert('`'.$this->definition->getTable().'`')
527
            ->setValue('created_at', 'UTC_TIMESTAMP()')
528
            ->setValue('updated_at', 'UTC_TIMESTAMP()')
529
            ->setValue('version', 0);
530
531
532
        $this->setValuesAndParameters($entity, $queryBuilder, 'setValue');
533
534
        $id = $this->generateUUID();
535
        if ($this->useUUIDs) {
536
            $queryBuilder->setValue('`id`', '?');
537
            $uuidI = count($this->getFormFields());
538
            $queryBuilder->setParameter($uuidI, $id);
539
        }
540
541
        $queryBuilder->execute();
542
543
        if (!$this->useUUIDs) {
544
            $id = $this->database->lastInsertId();
545
        }
546
547
        $this->enrichEntityWithMetaData($id, $entity);
548
        $this->saveMany($entity);
549
        $entities = [$entity];
550
        $this->enrichWithReference($entities);
551
552
        $this->shouldExecuteEvents($entity, 'after', 'create');
553
554
        return true;
555
    }
556
557
    /**
558
     * {@inheritdoc}
559
     */
560
    public function update(Entity $entity) {
561
562
        if (!$this->shouldExecuteEvents($entity, 'before', 'update')) {
563
            return false;
564
        }
565
566
        $queryBuilder = $this->database->createQueryBuilder();
567
        $queryBuilder->update('`'.$this->definition->getTable().'`')
568
            ->set('updated_at', 'UTC_TIMESTAMP()')
569
            ->set('version', 'version + 1')
570
            ->where('id = ?')
571
            ->setParameter(count($this->getFormFields()), $entity->get('id'));
572
573
        $this->setValuesAndParameters($entity, $queryBuilder, 'set');
574
        $affected = $queryBuilder->execute();
575
576
        $this->saveMany($entity);
577
        $entities = [$entity];
578
        $this->enrichWithReference($entities);
579
580
        $this->shouldExecuteEvents($entity, 'after', 'update');
581
582
        return $affected;
583
    }
584
585
    /**
586
     * {@inheritdoc}
587
     */
588
    public function getIdToNameMap($entity, $nameField) {
589
        $nameSelect   = $nameField !== null ? ',`'.$nameField.'`' : '';
590
        $drivingField = $nameField ?: 'id';
591
592
        $table        = $this->definition->getServiceProvider()->getData($entity)->getDefinition()->getTable();
593
        $queryBuilder = $this->database->createQueryBuilder();
594
        $queryBuilder
595
            ->select('id'.$nameSelect)
596
            ->from('`'.$table.'`', 't1')
597
            ->where('deleted_at IS NULL')
598
            ->orderBy($drivingField)
599
        ;
600
        $queryResult    = $queryBuilder->execute();
601
        $manyReferences = $queryResult->fetchAll(\PDO::FETCH_ASSOC);
602
        $result         = array_reduce($manyReferences, function(&$carry, $manyReference) use ($drivingField) {
603
            $carry[$manyReference['id']] = $manyReference[$drivingField];
604
            return $carry;
605
        }, []);
606
        return $result;
607
    }
608
609
    /**
610
     * {@inheritdoc}
611
     */
612
    public function countBy($table, array $params, array $paramsOperators, $excludeDeleted) {
613
        $queryBuilder = $this->database->createQueryBuilder();
614
        $queryBuilder
615
            ->select('COUNT(id)')
616
            ->from('`'.$table.'`', '`'.$table.'`')
617
        ;
618
619
        $deletedExcluder = 'where';
620
        $i               = 0;
621
        $manyFields      = [];
622
        foreach ($params as $name => $value) {
623
            if ($this->definition->getType($name) === 'many') {
624
                $manyFields[] = $name;
625
                continue;
626
            }
627
            $queryBuilder
628
                ->andWhere('`'.$name.'` '.$paramsOperators[$name].' ?')
629
                ->setParameter($i, $value, \PDO::PARAM_STR)
630
            ;
631
            $i++;
632
            $deletedExcluder = 'andWhere';
633
        }
634
635
        $idsToInclude = $this->getManyIds($manyFields, $params);
636
        if (!empty($idsToInclude)) {
637
            $queryBuilder
638
                ->andWhere('id IN (?)')
639
                ->setParameter($i, $idsToInclude, Connection::PARAM_STR_ARRAY)
640
            ;
641
            $deletedExcluder = 'andWhere';
642
        }
643
644
        if ($excludeDeleted) {
645
            $queryBuilder->$deletedExcluder('deleted_at IS NULL');
646
        }
647
648
        $queryResult = $queryBuilder->execute();
649
        $result      = $queryResult->fetch(\PDO::FETCH_NUM);
650
        return intval($result[0]);
651
    }
652
653
    /**
654
     * {@inheritdoc}
655
     */
656
    public function hasManySet($field, array $thatIds, $excludeId = null) {
657
        $thisField    = $this->definition->getSubTypeField($field, 'many', 'thisField');
658
        $thatField    = $this->definition->getSubTypeField($field, 'many', 'thatField');
659
        $thatEntity   = $this->definition->getSubTypeField($field, 'many', 'entity');
660
        $entityTable  = $this->definition->getServiceProvider()->getData($thatEntity)->getDefinition()->getTable();
661
        $queryBuilder = $this->database->createQueryBuilder();
662
        $queryBuilder->select('t1.`'.$thisField.'` AS this, t1.`'.$thatField.'` AS that')
663
            ->from('`'.$field.'`', 't1')
664
            ->leftJoin('t1', '`'.$entityTable.'`', 't2', 't2.id = t1.`'.$thatField.'`')
665
            ->where('t2.deleted_at IS NULL')
666
            ->orderBy('this, that');
667
        if ($excludeId !== null) {
668
            $queryBuilder->andWhere('t1.`'.$thisField.'` != ?')->setParameter(0, $excludeId);
669
        }
670
        $existingMany = $queryBuilder->execute()->fetchAll(\PDO::FETCH_ASSOC);
671
        $existingMap  = array_reduce($existingMany, function(&$carry, $existing) {
672
            $carry[$existing['this']][] = $existing['that'];
673
            return $carry;
674
        }, []);
675
        sort($thatIds);
676
        return in_array($thatIds, array_values($existingMap));
677
    }
678
679
}
680