Completed
Push — ezp-30882-thumbnail-strategy-i... ( de6b29...42b8c0 )
by
unknown
288:43 queued 276:39
created

DoctrineDatabase::loadPathDataByHierarchy()   B

Complexity

Conditions 5
Paths 12

Size

Total Lines 65

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 5
nc 12
nop 1
dl 0
loc 65
rs 8.4525
c 0
b 0
f 0

How to fix   Long Method   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

1
<?php
2
3
/**
4
 * @copyright Copyright (C) eZ Systems AS. All rights reserved.
5
 * @license For full copyright and license information view LICENSE file distributed with this source code.
6
 */
7
declare(strict_types=1);
8
9
namespace eZ\Publish\Core\Persistence\Legacy\Content\UrlAlias\Gateway;
10
11
use Doctrine\DBAL\Connection;
12
use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
13
use Doctrine\DBAL\FetchMode;
14
use Doctrine\DBAL\ParameterType;
15
use eZ\Publish\Core\Base\Exceptions\BadStateException;
16
use eZ\Publish\Core\Persistence\Legacy\Content\Language\MaskGenerator as LanguageMaskGenerator;
17
use eZ\Publish\Core\Persistence\Legacy\Content\UrlAlias\Gateway;
18
use RuntimeException;
19
20
/**
21
 * UrlAlias gateway implementation using the Doctrine database.
22
 *
23
 * @internal Gateway implementation is considered internal. Use Persistence UrlAlias Handler instead.
24
 *
25
 * @see \eZ\Publish\SPI\Persistence\Content\UrlAlias\Handler
26
 */
27
final class DoctrineDatabase extends Gateway
28
{
29
    /**
30
     * 2^30, since PHP_INT_MAX can cause overflows in DB systems, if PHP is run
31
     * on 64 bit systems.
32
     */
33
    const MAX_LIMIT = 1073741824;
34
35
    private const URL_ALIAS_DATA_COLUMN_TYPE_MAP = [
36
        'id' => ParameterType::INTEGER,
37
        'link' => ParameterType::INTEGER,
38
        'is_alias' => ParameterType::INTEGER,
39
        'alias_redirects' => ParameterType::INTEGER,
40
        'is_original' => ParameterType::INTEGER,
41
        'action' => ParameterType::STRING,
42
        'action_type' => ParameterType::STRING,
43
        'lang_mask' => ParameterType::INTEGER,
44
        'text' => ParameterType::STRING,
45
        'parent' => ParameterType::INTEGER,
46
        'text_md5' => ParameterType::STRING,
47
    ];
48
49
    /** @var \eZ\Publish\Core\Persistence\Legacy\Content\Language\MaskGenerator */
50
    private $languageMaskGenerator;
51
52
    /**
53
     * Main URL database table name.
54
     *
55
     * @var string
56
     */
57
    private $table;
58
59
    /** @var \Doctrine\DBAL\Connection */
60
    private $connection;
61
62
    /** @var \Doctrine\DBAL\Platforms\AbstractPlatform */
63
    private $dbPlatform;
64
65
    /**
66
     * @throws \Doctrine\DBAL\DBALException
67
     */
68
    public function __construct(
69
        Connection $connection,
70
        LanguageMaskGenerator $languageMaskGenerator
71
    ) {
72
        $this->connection = $connection;
73
        $this->languageMaskGenerator = $languageMaskGenerator;
74
        $this->table = static::TABLE;
75
        $this->dbPlatform = $this->connection->getDatabasePlatform();
76
    }
77
78
    public function setTable(string $name): void
79
    {
80
        $this->table = $name;
81
    }
82
83
    public function loadLocationEntries(
84
        int $locationId,
85
        bool $custom = false,
86
        ?int $languageId = null
87
    ): array {
88
        $query = $this->connection->createQueryBuilder();
89
        $expr = $query->expr();
90
        $query
91
            ->select(
92
                'id',
93
                'link',
94
                'is_alias',
95
                'alias_redirects',
96
                'lang_mask',
97
                'is_original',
98
                'parent',
99
                'text',
100
                'text_md5',
101
                'action'
102
            )
103
            ->from($this->connection->quoteIdentifier($this->table))
104
            ->where(
105
                $expr->eq(
106
                    'action',
107
                    $query->createPositionalParameter(
108
                        "eznode:{$locationId}",
109
                        ParameterType::STRING
110
                    )
111
                )
112
            )
113
            ->andWhere(
114
                $expr->eq(
115
                    'is_original',
116
                    $query->createPositionalParameter(1, ParameterType::INTEGER)
117
                )
118
            )
119
            ->andWhere(
120
                $expr->eq(
121
                    'is_alias',
122
                    $query->createPositionalParameter($custom ? 1 : 0, ParameterType::INTEGER)
123
                )
124
            )
125
        ;
126
127
        if (null !== $languageId) {
128
            $query->andWhere(
129
                $expr->gt(
130
                    $this->dbPlatform->getBitAndComparisonExpression(
131
                        'lang_mask',
132
                        $query->createPositionalParameter($languageId, ParameterType::INTEGER)
133
                    ),
134
                    0
135
                )
136
            );
137
        }
138
139
        $statement = $query->execute();
140
141
        return $statement->fetchAll(FetchMode::ASSOCIATIVE);
142
    }
143
144
    public function listGlobalEntries(
145
        ?string $languageCode = null,
146
        int $offset = 0,
147
        int $limit = -1
148
    ): array {
149
        $limit = $limit === -1 ? self::MAX_LIMIT : $limit;
150
151
        $query = $this->connection->createQueryBuilder();
152
        $expr = $query->expr();
153
        $query
154
            ->select(
155
                'action',
156
                'id',
157
                'link',
158
                'is_alias',
159
                'alias_redirects',
160
                'lang_mask',
161
                'is_original',
162
                'parent',
163
                'text_md5'
164
            )
165
            ->from($this->connection->quoteIdentifier($this->table))
166
            ->where(
167
                $expr->eq(
168
                    'action_type',
169
                    $query->createPositionalParameter(
170
                        'module',
171
                        ParameterType::STRING
172
                    )
173
                )
174
            )
175
            ->andWhere(
176
                $expr->eq(
177
                    'is_original',
178
                    $query->createPositionalParameter(1, ParameterType::INTEGER)
179
                )
180
            )
181
            ->andWhere(
182
                $expr->eq(
183
                    'is_alias',
184
                    $query->createPositionalParameter(1, ParameterType::INTEGER)
185
                )
186
            )
187
            ->setMaxResults(
188
                $limit
189
            )
190
            ->setFirstResult($offset);
191
192
        if (isset($languageCode)) {
193
            $query->andWhere(
194
                $expr->gt(
195
                    $this->dbPlatform->getBitAndComparisonExpression(
196
                        'lang_mask',
197
                        $query->createPositionalParameter(
198
                            $this->languageMaskGenerator->generateLanguageIndicator(
199
                                $languageCode,
200
                                false
201
                            ),
202
                            ParameterType::INTEGER
203
                        )
204
                    ),
205
                    0
206
                )
207
            );
208
        }
209
        $statement = $query->execute();
210
211
        return $statement->fetchAll(FetchMode::ASSOCIATIVE);
212
    }
213
214
    public function isRootEntry(int $id): bool
215
    {
216
        $query = $this->connection->createQueryBuilder();
217
        $query
218
            ->select(
219
                'text',
220
                'parent'
221
            )
222
            ->from($this->connection->quoteIdentifier($this->table))
223
            ->where(
224
                $query->expr()->eq(
225
                    'id',
226
                    $query->createPositionalParameter($id, ParameterType::INTEGER)
227
                )
228
            );
229
        $statement = $query->execute();
230
231
        $row = $statement->fetch(FetchMode::ASSOCIATIVE);
232
233
        return strlen($row['text']) == 0 && $row['parent'] == 0;
234
    }
235
236
    public function cleanupAfterPublish(
237
        string $action,
238
        int $languageId,
239
        int $newId,
240
        int $parentId,
241
        string $textMD5
242
    ): void {
243
        $query = $this->connection->createQueryBuilder();
244
        $expr = $query->expr();
245
        $query
246
            ->select(
247
                'parent',
248
                'text_md5',
249
                'lang_mask'
250
            )
251
            ->from($this->connection->quoteIdentifier($this->table))
252
            // 1) Autogenerated aliases that match action and language...
253
            ->where(
254
                $expr->eq(
255
                    'action',
256
                    $query->createPositionalParameter($action, ParameterType::STRING)
257
                )
258
            )
259
            ->andWhere(
260
                $expr->eq(
261
                    'is_original',
262
                    $query->createPositionalParameter(1, ParameterType::INTEGER)
263
                )
264
            )
265
            ->andWhere(
266
                $expr->eq(
267
                    'is_alias',
268
                    $query->createPositionalParameter(0, ParameterType::INTEGER)
269
                )
270
            )
271
            ->andWhere(
272
                $expr->gt(
273
                    $this->dbPlatform->getBitAndComparisonExpression(
274
                        'lang_mask',
275
                        $query->createPositionalParameter($languageId, ParameterType::INTEGER)
276
                    ),
277
                    0
278
                )
279
            )
280
            // 2) ...but not newly published entry
281
            ->andWhere(
282
                sprintf(
283
                    'NOT (%s)',
284
                    $expr->andX(
285
                        $expr->eq(
286
                            'parent',
287
                            $query->createPositionalParameter($parentId, ParameterType::INTEGER)
288
                        ),
289
                        $expr->eq(
290
                            'text_md5',
291
                            $query->createPositionalParameter($textMD5, ParameterType::STRING)
292
                        )
293
                    )
294
                )
295
            );
296
297
        $statement = $query->execute();
298
299
        $row = $statement->fetch(FetchMode::ASSOCIATIVE);
300
301
        if (!empty($row)) {
302
            $this->archiveUrlAliasForDeletedTranslation(
303
                (int)$row['lang_mask'],
304
                (int)$languageId,
305
                (int)$row['parent'],
306
                $row['text_md5'],
307
                (int)$newId
308
            );
309
        }
310
    }
311
312
    /**
313
     * Archive (remove or historize) obsolete URL aliases (for translations that were removed).
314
     *
315
     * @param int $languageMask all languages bit mask
316
     * @param int $languageId removed language Id
317
     * @param string $textMD5 checksum
318
     */
319
    private function archiveUrlAliasForDeletedTranslation(
320
        int $languageMask,
321
        int $languageId,
322
        int $parent,
323
        string $textMD5,
324
        int $linkId
325
    ): void {
326
        // If language mask is composite (consists of multiple languages) then remove given language from entry
327
        if ($languageMask & ~($languageId | 1)) {
328
            $this->removeTranslation($parent, $textMD5, $languageId);
329
        } else {
330
            // Otherwise mark entry as history
331
            $this->historize($parent, $textMD5, $linkId);
332
        }
333
    }
334
335
    public function historizeBeforeSwap(string $action, int $languageMask): void
336
    {
337
        $query = $this->connection->createQueryBuilder();
338
        $query
339
            ->update($this->connection->quoteIdentifier($this->table))
340
            ->set(
341
                'is_original',
342
                $query->createPositionalParameter(0, ParameterType::INTEGER)
343
            )
344
            ->set(
345
                'id',
346
                $query->createPositionalParameter(
347
                    $this->getNextId(),
348
                    ParameterType::INTEGER
349
                )
350
            )
351
            ->where(
352
                $query->expr()->andX(
353
                    $query->expr()->eq(
354
                        'action',
355
                        $query->createPositionalParameter($action, ParameterType::STRING)
356
                    ),
357
                    $query->expr()->eq(
358
                        'is_original',
359
                        $query->createPositionalParameter(1, ParameterType::INTEGER)
360
                    ),
361
                    $query->expr()->gt(
362
                        $this->dbPlatform->getBitAndComparisonExpression(
363
                            'lang_mask',
364
                            $query->createPositionalParameter(
365
                                $languageMask & ~1,
366
                                ParameterType::INTEGER
367
                            )
368
                        ),
369
                        0
370
                    )
371
                )
372
            );
373
374
        $query->execute();
375
    }
376
377
    /**
378
     * Update single row matched by composite primary key.
379
     *
380
     * Sets "is_original" to 0 thus marking entry as history.
381
     *
382
     * Re-links history entries.
383
     *
384
     * When location alias is published we need to check for new history entries created with self::downgrade()
385
     * with the same action and language, update their "link" column with id of the published entry.
386
     * History entry "id" column is moved to next id value so that all active (non-history) entries are kept
387
     * under the same id.
388
     */
389
    private function historize(int $parentId, string $textMD5, int $newId): void
390
    {
391
        $query = $this->connection->createQueryBuilder();
392
        $query
393
            ->update($this->connection->quoteIdentifier($this->table))
394
            ->set(
395
                'is_original',
396
                $query->createPositionalParameter(0, ParameterType::INTEGER)
397
            )
398
            ->set(
399
                'link',
400
                $query->createPositionalParameter($newId, ParameterType::INTEGER)
401
            )
402
            ->set(
403
                'id',
404
                $query->createPositionalParameter(
405
                    $this->getNextId(),
406
                    ParameterType::INTEGER
407
                )
408
            )
409
            ->where(
410
                $query->expr()->andX(
411
                    $query->expr()->eq(
412
                        'parent',
413
                        $query->createPositionalParameter($parentId, ParameterType::INTEGER)
414
                    ),
415
                    $query->expr()->eq(
416
                        'text_md5',
417
                        $query->createPositionalParameter($textMD5, ParameterType::STRING)
418
                    )
419
                )
420
            );
421
        $query->execute();
422
    }
423
424
    /**
425
     * Update single row data matched by composite primary key.
426
     *
427
     * Removes given $languageId from entry's language mask
428
     */
429
    private function removeTranslation(int $parentId, string $textMD5, int $languageId): void
430
    {
431
        $query = $this->connection->createQueryBuilder();
432
        $query
433
            ->update($this->connection->quoteIdentifier($this->table))
434
            ->set(
435
                'lang_mask',
436
                $this->dbPlatform->getBitAndComparisonExpression(
437
                    'lang_mask',
438
                    $query->createPositionalParameter(
439
                        ~$languageId,
440
                        ParameterType::INTEGER
441
                    )
442
                )
443
            )
444
            ->where(
445
                $query->expr()->eq(
446
                    'parent',
447
                    $query->createPositionalParameter(
448
                        $parentId,
449
                        ParameterType::INTEGER
450
                    )
451
                )
452
            )
453
            ->andWhere(
454
                $query->expr()->eq(
455
                    'text_md5',
456
                    $query->createPositionalParameter(
457
                        $textMD5,
458
                        ParameterType::STRING
459
                    )
460
                )
461
            )
462
        ;
463
        $query->execute();
464
    }
465
466
    public function historizeId(int $id, int $link): void
467
    {
468
        $query = $this->connection->createQueryBuilder();
469
        $query->select(
470
            'parent',
471
            'text_md5'
472
        )->from(
473
            $this->connection->quoteIdentifier($this->table)
474
        )->where(
475
            $query->expr()->andX(
476
                $query->expr()->eq(
477
                    'is_alias',
478
                    $query->createPositionalParameter(0, ParameterType::INTEGER)
479
                ),
480
                $query->expr()->eq(
481
                    'is_original',
482
                    $query->createPositionalParameter(1, ParameterType::INTEGER)
483
                ),
484
                $query->expr()->eq(
485
                    'action_type',
486
                    $query->createPositionalParameter(
487
                        'eznode',
488
                        ParameterType::STRING
489
                    )
490
                ),
491
                $query->expr()->eq(
492
                    'link',
493
                    $query->createPositionalParameter($id, ParameterType::INTEGER)
494
                )
495
            )
496
        );
497
498
        $statement = $query->execute();
499
500
        $rows = $statement->fetchAll(FetchMode::ASSOCIATIVE);
501
502
        foreach ($rows as $row) {
503
            $this->historize((int)$row['parent'], $row['text_md5'], $link);
504
        }
505
    }
506
507
    public function reparent(int $oldParentId, int $newParentId): void
508
    {
509
        $query = $this->connection->createQueryBuilder();
510
        $query->update(
511
            $this->connection->quoteIdentifier($this->table)
512
        )->set(
513
            'parent',
514
            $query->createPositionalParameter($newParentId, ParameterType::INTEGER)
515
        )->where(
516
            $query->expr()->andX(
517
                $query->expr()->eq(
518
                    'is_alias',
519
                    $query->createPositionalParameter(0, ParameterType::INTEGER)
520
                ),
521
                $query->expr()->eq(
522
                    'parent',
523
                    $query->createPositionalParameter(
524
                        $oldParentId,
525
                        ParameterType::INTEGER
526
                    )
527
                )
528
            )
529
        );
530
531
        $query->execute();
532
    }
533
534
    public function updateRow(int $parentId, string $textMD5, array $values): void
535
    {
536
        $query = $this->connection->createQueryBuilder();
537
        $query->update($this->connection->quoteIdentifier($this->table));
538
        foreach ($values as $columnName => $value) {
539
            $query->set(
540
                $columnName,
541
                $query->createNamedParameter(
542
                    $value,
543
                    self::URL_ALIAS_DATA_COLUMN_TYPE_MAP[$columnName],
544
                    ":{$columnName}"
545
                )
546
            );
547
        }
548
        $query
549
            ->where(
550
                $query->expr()->eq(
551
                    'parent',
552
                    $query->createNamedParameter($parentId, ParameterType::INTEGER, ':parent')
553
                )
554
            )
555
            ->andWhere(
556
                $query->expr()->eq(
557
                    'text_md5',
558
                    $query->createNamedParameter($textMD5, ParameterType::STRING, ':text_md5')
559
                )
560
            );
561
        $query->execute();
562
    }
563
564
    public function insertRow(array $values): int
565
    {
566
        if (!isset($values['id'])) {
567
            $values['id'] = $this->getNextId();
568
        }
569
        if (!isset($values['link'])) {
570
            $values['link'] = $values['id'];
571
        }
572
        if (!isset($values['is_original'])) {
573
            $values['is_original'] = ($values['id'] == $values['link'] ? 1 : 0);
574
        }
575
        if (!isset($values['is_alias'])) {
576
            $values['is_alias'] = 0;
577
        }
578
        if (!isset($values['alias_redirects'])) {
579
            $values['alias_redirects'] = 0;
580
        }
581
        if (
582
            !isset($values['action_type'])
583
            && preg_match('#^(.+):.*#', $values['action'], $matches)
584
        ) {
585
            $values['action_type'] = $matches[1];
586
        }
587
        if ($values['is_alias']) {
588
            $values['is_original'] = 1;
589
        }
590
        if ($values['action'] === 'nop:') {
591
            $values['is_original'] = 0;
592
        }
593
594
        $query = $this->connection->createQueryBuilder();
595
        $query->insert($this->connection->quoteIdentifier($this->table));
596
        foreach ($values as $columnName => $value) {
597
            $query->setValue(
598
                $columnName,
599
                $query->createNamedParameter(
600
                    $value,
601
                    self::URL_ALIAS_DATA_COLUMN_TYPE_MAP[$columnName],
602
                    ":{$columnName}"
603
                )
604
            );
605
        }
606
        $query->execute();
607
608
        return (int)$values['id'];
609
    }
610
611
    public function getNextId(): int
612
    {
613
        $query = $this->connection->createQueryBuilder();
614
        $query
615
            ->insert(self::INCR_TABLE)
616
            ->values(
617
                [
618
                    'id' => $this->dbPlatform->supportsSequences()
619
                        ? sprintf('NEXTVAL(\'%s\')', self::INCR_TABLE_SEQ)
620
                        : $query->createPositionalParameter(null, ParameterType::NULL),
621
                ]
622
            );
623
624
        $query->execute();
625
626
        return (int)$this->connection->lastInsertId(self::INCR_TABLE_SEQ);
627
    }
628
629
    public function loadRow(int $parentId, string $textMD5): array
630
    {
631
        $query = $this->connection->createQueryBuilder();
632
        $query->select('*')->from(
633
            $this->connection->quoteIdentifier($this->table)
634
        )->where(
635
            $query->expr()->andX(
636
                $query->expr()->eq(
637
                    'parent',
638
                    $query->createPositionalParameter(
639
                        $parentId,
640
                        ParameterType::INTEGER
641
                    )
642
                ),
643
                $query->expr()->eq(
644
                    'text_md5',
645
                    $query->createPositionalParameter(
646
                        $textMD5,
647
                        ParameterType::STRING
648
                    )
649
                )
650
            )
651
        );
652
653
        $result = $query->execute()->fetch(FetchMode::ASSOCIATIVE);
654
655
        return false !== $result ? $result : [];
656
    }
657
658
    public function loadUrlAliasData(array $urlHashes): array
659
    {
660
        $query = $this->connection->createQueryBuilder();
661
        $expr = $query->expr();
662
663
        $count = count($urlHashes);
664
        foreach ($urlHashes as $level => $urlPartHash) {
665
            $tableAlias = $level !== $count - 1 ? $this->table . $level : 'u';
666
            $query
667
                ->addSelect(
668
                    array_map(
669
                        function (string $columnName) use ($tableAlias) {
670
                            // do not alias data for top level url part
671
                            $columnAlias = 'u' === $tableAlias
672
                                ? $columnName
673
                                : "{$tableAlias}_{$columnName}";
674
                            $columnName = "{$tableAlias}.{$columnName}";
675
676
                            return "{$columnName} AS {$columnAlias}";
677
                        },
678
                        array_keys(self::URL_ALIAS_DATA_COLUMN_TYPE_MAP)
679
                    )
680
                )
681
                ->from($this->connection->quoteIdentifier($this->table), $tableAlias);
682
683
            $query
684
                ->andWhere(
685
                    $expr->eq(
686
                        "{$tableAlias}.text_md5",
687
                        $query->createPositionalParameter($urlPartHash, ParameterType::STRING)
688
                    )
689
                )
690
                ->andWhere(
691
                    $expr->eq(
692
                        "{$tableAlias}.parent",
693
                        // root entry has parent column set to 0
694
                        isset($previousTableName) ? $previousTableName . '.link' : $query->createPositionalParameter(
695
                            0,
696
                            ParameterType::INTEGER
697
                        )
698
                    )
699
                );
700
701
            $previousTableName = $tableAlias;
702
        }
703
        $query->setMaxResults(1);
704
705
        $result = $query->execute()->fetch(FetchMode::ASSOCIATIVE);
706
707
        return false !== $result ? $result : [];
708
    }
709
710
    public function loadAutogeneratedEntry(string $action, ?int $parentId = null): array
711
    {
712
        $query = $this->connection->createQueryBuilder();
713
        $query->select(
714
            '*'
715
        )->from(
716
            $this->connection->quoteIdentifier($this->table)
717
        )->where(
718
            $query->expr()->andX(
719
                $query->expr()->eq(
720
                    'action',
721
                    $query->createPositionalParameter($action, ParameterType::STRING)
722
                ),
723
                $query->expr()->eq(
724
                    'is_original',
725
                    $query->createPositionalParameter(1, ParameterType::INTEGER)
726
                ),
727
                $query->expr()->eq(
728
                    'is_alias',
729
                    $query->createPositionalParameter(0, ParameterType::INTEGER)
730
                )
731
            )
732
        );
733
734
        if (isset($parentId)) {
735
            $query->andWhere(
736
                $query->expr()->eq(
737
                    'parent',
738
                    $query->createPositionalParameter(
739
                        $parentId,
740
                        ParameterType::INTEGER
741
                    )
742
                )
743
            );
744
        }
745
746
        $entry = $query->execute()->fetch(FetchMode::ASSOCIATIVE);
747
748
        return false !== $entry ? $entry : [];
749
    }
750
751
    public function loadPathData(int $id): array
752
    {
753
        $pathData = [];
754
755
        while ($id != 0) {
756
            $query = $this->connection->createQueryBuilder();
757
            $query->select(
758
                'parent',
759
                'lang_mask',
760
                'text'
761
            )->from(
762
                $this->connection->quoteIdentifier($this->table)
763
            )->where(
764
                $query->expr()->eq(
765
                    'id',
766
                    $query->createPositionalParameter($id, ParameterType::INTEGER)
767
                )
768
            );
769
770
            $statement = $query->execute();
771
772
            $rows = $statement->fetchAll(FetchMode::ASSOCIATIVE);
773
            if (empty($rows)) {
774
                // Normally this should never happen
775
                $pathDataArray = [];
776
                foreach ($pathData as $path) {
777
                    if (!isset($path[0]['text'])) {
778
                        continue;
779
                    }
780
781
                    $pathDataArray[] = $path[0]['text'];
782
                }
783
784
                $path = implode('/', $pathDataArray);
785
                throw new BadStateException(
786
                    'id',
787
                    "Unable to load path data, path '{$path}' is broken, alias with ID '{$id}' not found. " .
788
                    'To fix all broken paths run the ezplatform:urls:regenerate-aliases command'
789
                );
790
            }
791
792
            $id = $rows[0]['parent'];
793
            array_unshift($pathData, $rows);
794
        }
795
796
        return $pathData;
797
    }
798
799
    public function loadPathDataByHierarchy(array $hierarchyData): array
800
    {
801
        $query = $this->connection->createQueryBuilder();
802
803
        $hierarchyConditions = [];
804
        foreach ($hierarchyData as $levelData) {
805
            $hierarchyConditions[] = $query->expr()->andX(
806
                $query->expr()->eq(
807
                    'parent',
808
                    $query->createPositionalParameter(
809
                        $levelData['parent'],
810
                        ParameterType::INTEGER
811
                    )
812
                ),
813
                $query->expr()->eq(
814
                    'action',
815
                    $query->createPositionalParameter(
816
                        $levelData['action'],
817
                        ParameterType::STRING
818
                    )
819
                ),
820
                $query->expr()->eq(
821
                    'id',
822
                    $query->createPositionalParameter(
823
                        $levelData['id'],
824
                        ParameterType::INTEGER
825
                    )
826
                )
827
            );
828
        }
829
830
        $query->select(
831
            'action',
832
            'lang_mask',
833
            'text'
834
        )->from(
835
            $this->connection->quoteIdentifier($this->table)
836
        )->where(
837
            $query->expr()->orX(...$hierarchyConditions)
838
        );
839
840
        $statement = $query->execute();
841
842
        $rows = $statement->fetchAll(FetchMode::ASSOCIATIVE);
843
        $rowsMap = [];
844
        foreach ($rows as $row) {
845
            $rowsMap[$row['action']][] = $row;
846
        }
847
848
        if (count($rowsMap) !== count($hierarchyData)) {
849
            throw new RuntimeException('The path is corrupted.');
850
        }
851
852
        $data = [];
853
        foreach ($hierarchyData as $levelData) {
854
            $data[] = $rowsMap[$levelData['action']];
855
        }
856
857
        return $data;
858
    }
859
860
    public function removeCustomAlias(int $parentId, string $textMD5): bool
861
    {
862
        $query = $this->connection->createQueryBuilder();
863
        $query->delete(
864
            $this->connection->quoteIdentifier($this->table)
865
        )->where(
866
            $query->expr()->andX(
867
                $query->expr()->eq(
868
                    'parent',
869
                    $query->createPositionalParameter(
870
                        $parentId,
871
                        ParameterType::INTEGER
872
                    )
873
                ),
874
                $query->expr()->eq(
875
                    'text_md5',
876
                    $query->createPositionalParameter(
877
                        $textMD5,
878
                        ParameterType::STRING
879
                    )
880
                ),
881
                $query->expr()->eq(
882
                    'is_alias',
883
                    $query->createPositionalParameter(1, ParameterType::INTEGER)
884
                )
885
            )
886
        );
887
888
        return $query->execute() === 1;
889
    }
890
891
    public function remove(string $action, ?int $id = null): void
892
    {
893
        $query = $this->connection->createQueryBuilder();
894
        $expr = $query->expr();
895
        $query
896
            ->delete($this->connection->quoteIdentifier($this->table))
897
            ->where(
898
                $expr->eq(
899
                    'action',
900
                    $query->createPositionalParameter($action, ParameterType::STRING)
901
                )
902
            );
903
904
        if ($id !== null) {
905
            $query
906
                ->andWhere(
907
                    $expr->eq(
908
                        'is_alias',
909
                        $query->createPositionalParameter(0, ParameterType::INTEGER)
910
                    ),
911
                    )
0 ignored issues
show
Bug introduced by
This code did not parse for me. Apparently, there is an error somewhere around this line:

Syntax error, unexpected ')'
Loading history...
912
                ->andWhere(
913
                    $expr->eq(
914
                        'id',
915
                        $query->createPositionalParameter(
916
                            $id,
917
                            ParameterType::INTEGER
918
                        )
919
                    )
920
                );
921
        }
922
923
        $query->execute();
924
    }
925
926
    public function loadAutogeneratedEntries(int $parentId, bool $includeHistory = false): array
927
    {
928
        $query = $this->connection->createQueryBuilder();
929
        $expr = $query->expr();
930
        $query
931
            ->select('*')
932
            ->from($this->connection->quoteIdentifier($this->table))
933
            ->where(
934
                $expr->eq(
935
                    'parent',
936
                    $query->createPositionalParameter(
937
                        $parentId,
938
                        ParameterType::INTEGER
939
                    )
940
                ),
941
                )
942
            ->andWhere(
943
                $expr->eq(
944
                    'action_type',
945
                    $query->createPositionalParameter(
946
                        'eznode',
947
                        ParameterType::STRING
948
                    )
949
                )
950
            )
951
            ->andWhere(
952
                $expr->eq(
953
                    'is_alias',
954
                    $query->createPositionalParameter(0, ParameterType::INTEGER)
955
                )
956
            );
957
958
        if (!$includeHistory) {
959
            $query->andWhere(
960
                $expr->eq(
961
                    'is_original',
962
                    $query->createPositionalParameter(1, ParameterType::INTEGER)
963
                )
964
            );
965
        }
966
967
        $statement = $query->execute();
968
969
        return $statement->fetchAll(FetchMode::ASSOCIATIVE);
970
    }
971
972
    public function getLocationContentMainLanguageId(int $locationId): int
973
    {
974
        $queryBuilder = $this->connection->createQueryBuilder();
975
        $expr = $queryBuilder->expr();
976
        $queryBuilder
977
            ->select('c.initial_language_id')
978
            ->from('ezcontentobject', 'c')
979
            ->join('c', 'ezcontentobject_tree', 't', $expr->eq('t.contentobject_id', 'c.id'))
980
            ->where(
981
                $expr->eq('t.node_id', ':locationId')
982
            )
983
            ->setParameter('locationId', $locationId, ParameterType::INTEGER);
984
985
        $statement = $queryBuilder->execute();
986
        $languageId = $statement->fetchColumn();
987
988
        if ($languageId === false) {
989
            throw new RuntimeException("Could not find Content for Location #{$locationId}");
990
        }
991
992
        return (int)$languageId;
993
    }
994
995
    public function bulkRemoveTranslation(int $languageId, array $actions): void
996
    {
997
        $query = $this->connection->createQueryBuilder();
998
        $query
999
            ->update($this->connection->quoteIdentifier($this->table))
1000
            // parameter for bitwise operation has to be placed verbatim (w/o binding) for this to work cross-DBMS
1001
            ->set('lang_mask', 'lang_mask & ~ ' . $languageId)
1002
            ->where('action IN (:actions)')
1003
            ->setParameter(':actions', $actions, Connection::PARAM_STR_ARRAY);
1004
        $query->execute();
1005
1006
        // cleanup: delete single language rows (including alwaysAvailable)
1007
        $query = $this->connection->createQueryBuilder();
1008
        $query
1009
            ->delete($this->connection->quoteIdentifier($this->table))
1010
            ->where('action IN (:actions)')
1011
            ->andWhere('lang_mask IN (0, 1)')
1012
            ->setParameter(':actions', $actions, Connection::PARAM_STR_ARRAY);
1013
        $query->execute();
1014
    }
1015
1016
    public function archiveUrlAliasesForDeletedTranslations(
1017
        int $locationId,
1018
        int $parentId,
1019
        array $languageIds
1020
    ): void {
1021
        // determine proper parent for linking historized entry
1022
        $existingLocationEntry = $this->loadAutogeneratedEntry(
1023
            'eznode:' . $locationId,
1024
            $parentId
1025
        );
1026
1027
        // filter existing URL alias entries by any of the specified removed languages
1028
        $rows = $this->loadLocationEntriesMatchingMultipleLanguages(
1029
            $locationId,
1030
            $languageIds
1031
        );
1032
1033
        // remove specific languages from a bit mask
1034
        foreach ($rows as $row) {
1035
            // filter mask to reduce the number of calls to storage engine
1036
            $rowLanguageMask = (int)$row['lang_mask'];
1037
            $languageIdsToBeRemoved = array_filter(
1038
                $languageIds,
1039
                function ($languageId) use ($rowLanguageMask) {
1040
                    return $languageId & $rowLanguageMask;
1041
                }
1042
            );
1043
1044
            if (empty($languageIdsToBeRemoved)) {
1045
                continue;
1046
            }
1047
1048
            // use existing entry to link archived alias or use current alias id
1049
            $linkToId = !empty($existingLocationEntry)
1050
                ? (int)$existingLocationEntry['id']
1051
                : (int)$row['id'];
1052
            foreach ($languageIdsToBeRemoved as $languageId) {
1053
                $this->archiveUrlAliasForDeletedTranslation(
1054
                    (int)$row['lang_mask'],
1055
                    (int)$languageId,
1056
                    (int)$row['parent'],
1057
                    $row['text_md5'],
1058
                    $linkToId
1059
                );
1060
            }
1061
        }
1062
    }
1063
1064
    /**
1065
     * Load list of aliases for given $locationId matching any of the specified Languages.
1066
     *
1067
     * @param int[] $languageIds
1068
     */
1069
    private function loadLocationEntriesMatchingMultipleLanguages(
1070
        int $locationId,
1071
        array $languageIds
1072
    ): array {
1073
        // note: alwaysAvailable for this use case is not relevant
1074
        $languageMask = $this->languageMaskGenerator->generateLanguageMaskFromLanguageIds(
1075
            $languageIds,
1076
            false
1077
        );
1078
1079
        /** @var \Doctrine\DBAL\Connection $connection */
1080
        $query = $this->connection->createQueryBuilder();
1081
        $query
1082
            ->select('id', 'lang_mask', 'parent', 'text_md5')
1083
            ->from($this->connection->quoteIdentifier($this->table))
1084
            ->where('action = :action')
1085
            // fetch rows matching any of the given Languages
1086
            ->andWhere('lang_mask & :languageMask <> 0')
1087
            ->setParameter(':action', 'eznode:' . $locationId)
1088
            ->setParameter(':languageMask', $languageMask);
1089
1090
        $statement = $query->execute();
1091
1092
        return $statement->fetchAll(FetchMode::ASSOCIATIVE);
1093
    }
1094
1095
    /**
1096
     * @throws \Doctrine\DBAL\DBALException
1097
     */
1098
    public function deleteUrlAliasesWithoutLocation(): int
1099
    {
1100
        $dbPlatform = $this->connection->getDatabasePlatform();
1101
1102
        $subQuery = $this->connection->createQueryBuilder();
1103
        $subQuery
1104
            ->select('node_id')
1105
            ->from('ezcontentobject_tree', 't')
1106
            ->where(
1107
                $subQuery->expr()->eq(
1108
                    't.node_id',
1109
                    sprintf(
1110
                        'CAST(%s as %s)',
1111
                        $dbPlatform->getSubstringExpression(
1112
                            $this->connection->quoteIdentifier($this->table) . '.action',
1113
                            8
1114
                        ),
1115
                        $this->getIntegerType()
1116
                    )
1117
                )
1118
            );
1119
1120
        $deleteQuery = $this->connection->createQueryBuilder();
1121
        $deleteQuery
1122
            ->delete($this->connection->quoteIdentifier($this->table))
1123
            ->where(
1124
                $deleteQuery->expr()->eq(
1125
                    'action_type',
1126
                    $deleteQuery->createPositionalParameter('eznode')
1127
                )
1128
            )
1129
            ->andWhere(
1130
                sprintf('NOT EXISTS (%s)', $subQuery->getSQL())
1131
            );
1132
1133
        return $deleteQuery->execute();
1134
    }
1135
1136
    public function deleteUrlAliasesWithoutParent(): int
1137
    {
1138
        $existingAliasesQuery = $this->getAllUrlAliasesQuery();
1139
1140
        $query = $this->connection->createQueryBuilder();
1141
        $query
1142
            ->delete($this->connection->quoteIdentifier($this->table))
1143
            ->where(
1144
                $query->expr()->neq(
1145
                    'parent',
1146
                    $query->createPositionalParameter(0, ParameterType::INTEGER)
1147
                )
1148
            )
1149
            ->andWhere(
1150
                $query->expr()->notIn(
1151
                    'parent',
1152
                    $existingAliasesQuery
1153
                )
1154
            );
1155
1156
        return $query->execute();
1157
    }
1158
1159
    public function deleteUrlAliasesWithBrokenLink(): int
1160
    {
1161
        $existingAliasesQuery = $this->getAllUrlAliasesQuery();
1162
1163
        $query = $this->connection->createQueryBuilder();
1164
        $query
1165
            ->delete($this->connection->quoteIdentifier($this->table))
1166
            ->where(
1167
                $query->expr()->neq('id', 'link')
1168
            )
1169
            ->andWhere(
1170
                $query->expr()->notIn(
1171
                    'link',
1172
                    $existingAliasesQuery
1173
                )
1174
            );
1175
1176
        return (int)$query->execute();
1177
    }
1178
1179
    public function repairBrokenUrlAliasesForLocation(int $locationId): void
1180
    {
1181
        $urlAliasesData = $this->getUrlAliasesForLocation($locationId);
1182
1183
        $originalUrlAliases = $this->filterOriginalAliases($urlAliasesData);
1184
1185
        if (count($originalUrlAliases) === count($urlAliasesData)) {
1186
            // no archived aliases - nothing to fix
1187
            return;
1188
        }
1189
1190
        $updateQueryBuilder = $this->connection->createQueryBuilder();
1191
        $expr = $updateQueryBuilder->expr();
1192
        $updateQueryBuilder
1193
            ->update($this->connection->quoteIdentifier($this->table))
1194
            ->set('link', ':linkId')
1195
            ->set('parent', ':newParentId')
1196
            ->where(
1197
                $expr->eq('action', ':action')
1198
            )
1199
            ->andWhere(
1200
                $expr->eq(
1201
                    'is_original',
1202
                    $updateQueryBuilder->createNamedParameter(0, ParameterType::INTEGER)
1203
                )
1204
            )
1205
            ->andWhere(
1206
                $expr->eq('parent', ':oldParentId')
1207
            )
1208
            ->andWhere(
1209
                $expr->eq('text_md5', ':textMD5')
1210
            )
1211
            ->setParameter(':action', "eznode:{$locationId}");
1212
1213
        foreach ($urlAliasesData as $urlAliasData) {
1214
            if ($urlAliasData['is_original'] === 1 || !isset($originalUrlAliases[$urlAliasData['lang_mask']])) {
1215
                // ignore non-archived entries and deleted Translations
1216
                continue;
1217
            }
1218
1219
            $originalUrlAlias = $originalUrlAliases[$urlAliasData['lang_mask']];
1220
1221
            if ($urlAliasData['link'] === $originalUrlAlias['link']) {
1222
                // ignore correct entries to avoid unnecessary updates
1223
                continue;
1224
            }
1225
1226
            $updateQueryBuilder
1227
                ->setParameter(':linkId', $originalUrlAlias['link'], ParameterType::INTEGER)
1228
                // attempt to fix missing parent case
1229
                ->setParameter(
1230
                    ':newParentId',
1231
                    $urlAliasData['existing_parent'] ?? $originalUrlAlias['parent'],
1232
                    ParameterType::INTEGER
1233
                )
1234
                ->setParameter(':oldParentId', $urlAliasData['parent'], ParameterType::INTEGER)
1235
                ->setParameter(':textMD5', $urlAliasData['text_md5']);
1236
1237
            try {
1238
                $updateQueryBuilder->execute();
1239
            } catch (UniqueConstraintViolationException $e) {
1240
                // edge case: if such row already exists, there's no way to restore history
1241
                $this->deleteRow($urlAliasData['parent'], $urlAliasData['text_md5']);
1242
            }
1243
        }
1244
    }
1245
1246
    /**
1247
     * Filter from the given result set original (current) only URL aliases and index them by language_mask.
1248
     *
1249
     * Note: each language_mask can have one URL Alias.
1250
     *
1251
     * @param array $urlAliasesData
1252
     */
1253
    private function filterOriginalAliases(array $urlAliasesData): array
1254
    {
1255
        $originalUrlAliases = array_filter(
1256
            $urlAliasesData,
1257
            function ($urlAliasData) {
1258
                // filter is_original=true ignoring broken parent records (cleaned up elsewhere)
1259
                return (bool)$urlAliasData['is_original'] && $urlAliasData['existing_parent'] !== null;
1260
            }
1261
        );
1262
1263
        // return language_mask-indexed array
1264
        return array_combine(
1265
            array_column($originalUrlAliases, 'lang_mask'),
1266
            $originalUrlAliases
1267
        );
1268
    }
1269
1270
    /**
1271
     * Get sub-query for IDs of all URL aliases.
1272
     */
1273
    private function getAllUrlAliasesQuery(): string
1274
    {
1275
        $existingAliasesQueryBuilder = $this->connection->createQueryBuilder();
1276
        $innerQueryBuilder = $this->connection->createQueryBuilder();
1277
1278
        return $existingAliasesQueryBuilder
1279
            ->select('tmp.id')
1280
            ->from(
1281
            // nest sub-query to avoid same-table update error
1282
                '(' . $innerQueryBuilder->select('id')->from(
1283
                    $this->connection->quoteIdentifier($this->table)
1284
                )->getSQL() . ')',
1285
                'tmp'
1286
            )
1287
            ->getSQL();
1288
    }
1289
1290
    /**
1291
     * Get DBMS-specific integer type.
1292
     */
1293
    private function getIntegerType(): string
1294
    {
1295
        return $this->dbPlatform->getName() === 'mysql' ? 'signed' : 'integer';
1296
    }
1297
1298
    /**
1299
     * Get all URL aliases for the given Location (including archived ones).
1300
     */
1301
    private function getUrlAliasesForLocation(int $locationId): array
1302
    {
1303
        $queryBuilder = $this->connection->createQueryBuilder();
1304
        $queryBuilder
1305
            ->select(
1306
                't1.id',
1307
                't1.is_original',
1308
                't1.lang_mask',
1309
                't1.link',
1310
                't1.parent',
1311
                // show existing parent only if its row exists, special case for root parent
1312
                'CASE t1.parent WHEN 0 THEN 0 ELSE t2.id END AS existing_parent',
1313
                't1.text_md5'
1314
            )
1315
            ->from($this->connection->quoteIdentifier($this->table), 't1')
1316
            // selecting t2.id above will result in null if parent is broken
1317
            ->leftJoin(
1318
                't1',
1319
                $this->connection->quoteIdentifier($this->table),
1320
                't2',
1321
                $queryBuilder->expr()->eq('t1.parent', 't2.id')
1322
            )
1323
            ->where(
1324
                $queryBuilder->expr()->eq(
1325
                    't1.action',
1326
                    $queryBuilder->createPositionalParameter("eznode:{$locationId}")
1327
                )
1328
            );
1329
1330
        return $queryBuilder->execute()->fetchAll(FetchMode::ASSOCIATIVE);
1331
    }
1332
1333
    /**
1334
     * Delete URL alias row by its primary composite key.
1335
     */
1336
    private function deleteRow(int $parentId, string $textMD5): int
1337
    {
1338
        $queryBuilder = $this->connection->createQueryBuilder();
1339
        $expr = $queryBuilder->expr();
1340
        $queryBuilder
1341
            ->delete($this->connection->quoteIdentifier($this->table))
1342
            ->where(
1343
                $expr->eq(
1344
                    'parent',
1345
                    $queryBuilder->createPositionalParameter($parentId, ParameterType::INTEGER)
1346
                )
1347
            )
1348
            ->andWhere(
1349
                $expr->eq(
1350
                    'text_md5',
1351
                    $queryBuilder->createPositionalParameter($textMD5)
1352
                )
1353
            )
1354
        ;
1355
1356
        return $queryBuilder->execute();
1357
    }
1358
}
1359