Completed
Push — master ( ae9952...b2a7f6 )
by
unknown
137:19 queued 113:13
created

Handler::getNamesForAllLanguages()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 17

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 2
nc 2
nop 1
dl 0
loc 17
rs 9.7
c 0
b 0
f 0
1
<?php
2
3
/**
4
 * File containing the UrlAlias Handler.
5
 *
6
 * @copyright Copyright (C) eZ Systems AS. All rights reserved.
7
 * @license For full copyright and license information view LICENSE file distributed with this source code.
8
 */
9
namespace eZ\Publish\Core\Persistence\Legacy\Content\UrlAlias;
10
11
use eZ\Publish\Core\Base\Exceptions\BadStateException;
12
use eZ\Publish\Core\Base\Exceptions\InvalidArgumentException;
13
use eZ\Publish\Core\Persistence\Legacy\Content\Language\MaskGenerator;
14
use eZ\Publish\Core\Persistence\Legacy\Content\UrlAlias\DTO\SwappedLocationProperties;
15
use eZ\Publish\Core\Persistence\Legacy\Content\UrlAlias\DTO\UrlAliasForSwappedLocation;
16
use eZ\Publish\SPI\Persistence\Content\Language;
17
use eZ\Publish\SPI\Persistence\Content\UrlAlias\Handler as UrlAliasHandlerInterface;
18
use eZ\Publish\SPI\Persistence\Content\Language\Handler as LanguageHandler;
19
use eZ\Publish\Core\Persistence\Legacy\Content\Gateway as ContentGateway;
20
use eZ\Publish\Core\Persistence\Legacy\Content\Location\Gateway as LocationGateway;
21
use eZ\Publish\Core\Base\Exceptions\NotFoundException;
22
use eZ\Publish\Core\Base\Exceptions\ForbiddenException;
23
use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
24
use eZ\Publish\SPI\Persistence\TransactionHandler;
25
26
/**
27
 * The UrlAlias Handler provides nice urls management.
28
 *
29
 * Its methods operate on a representation of the url alias data structure held
30
 * inside a storage engine.
31
 */
32
class Handler implements UrlAliasHandlerInterface
33
{
34
    const ROOT_LOCATION_ID = 1;
35
36
    /**
37
     * This is intentionally hardcoded for now as:
38
     * 1. We don't implement this configuration option.
39
     * 2. Such option should not be in this layer, should be handled higher up.
40
     *
41
     * @deprecated
42
     */
43
    const CONTENT_REPOSITORY_ROOT_LOCATION_ID = 2;
44
45
    /**
46
     * The maximum level of alias depth.
47
     */
48
    const MAX_URL_ALIAS_DEPTH_LEVEL = 60;
49
50
    /**
51
     * UrlAlias Gateway.
52
     *
53
     * @var \eZ\Publish\Core\Persistence\Legacy\Content\UrlAlias\Gateway
54
     */
55
    protected $gateway;
56
57
    /**
58
     * Gateway for handling location data.
59
     *
60
     * @var \eZ\Publish\Core\Persistence\Legacy\Content\Location\Gateway
61
     */
62
    protected $locationGateway;
63
64
    /**
65
     * UrlAlias Mapper.
66
     *
67
     * @var \eZ\Publish\Core\Persistence\Legacy\Content\UrlAlias\Mapper
68
     */
69
    protected $mapper;
70
71
    /**
72
     * Caching language handler.
73
     *
74
     * @var \eZ\Publish\Core\Persistence\Legacy\Content\Language\CachingHandler
75
     */
76
    protected $languageHandler;
77
78
    /**
79
     * URL slug converter.
80
     *
81
     * @var \eZ\Publish\Core\Persistence\Legacy\Content\UrlAlias\SlugConverter
82
     */
83
    protected $slugConverter;
84
85
    /**
86
     * Gateway for handling content data.
87
     *
88
     * @var \eZ\Publish\Core\Persistence\Legacy\Content\Gateway
89
     */
90
    protected $contentGateway;
91
92
    /**
93
     * Language mask generator.
94
     *
95
     * @var \eZ\Publish\Core\Persistence\Legacy\Content\Language\MaskGenerator
96
     */
97
    protected $maskGenerator;
98
99
    /**
100
     * @var \eZ\Publish\SPI\Persistence\TransactionHandler
101
     */
102
    private $transactionHandler;
103
104
    /**
105
     * Creates a new UrlAlias Handler.
106
     *
107
     * @param \eZ\Publish\Core\Persistence\Legacy\Content\UrlAlias\Gateway $gateway
108
     * @param \eZ\Publish\Core\Persistence\Legacy\Content\UrlAlias\Mapper $mapper
109
     * @param \eZ\Publish\Core\Persistence\Legacy\Content\Location\Gateway $locationGateway
110
     * @param \eZ\Publish\SPI\Persistence\Content\Language\Handler $languageHandler
111
     * @param \eZ\Publish\Core\Persistence\Legacy\Content\UrlAlias\SlugConverter $slugConverter
112
     * @param \eZ\Publish\Core\Persistence\Legacy\Content\Gateway $contentGateway
113
     * @param \eZ\Publish\Core\Persistence\Legacy\Content\Language\MaskGenerator $maskGenerator
114
     * @param \eZ\Publish\SPI\Persistence\TransactionHandler $transactionHandler
115
     */
116
    public function __construct(
117
        Gateway $gateway,
118
        Mapper $mapper,
119
        LocationGateway $locationGateway,
120
        LanguageHandler $languageHandler,
121
        SlugConverter $slugConverter,
122
        ContentGateway $contentGateway,
123
        MaskGenerator $maskGenerator,
124
        TransactionHandler $transactionHandler
125
    ) {
126
        $this->gateway = $gateway;
127
        $this->mapper = $mapper;
128
        $this->locationGateway = $locationGateway;
129
        $this->languageHandler = $languageHandler;
0 ignored issues
show
Documentation Bug introduced by
$languageHandler is of type object<eZ\Publish\SPI\Pe...ntent\Language\Handler>, but the property $languageHandler was declared to be of type object<eZ\Publish\Core\P...anguage\CachingHandler>. Are you sure that you always receive this specific sub-class here, or does it make sense to add an instanceof check?

Our type inference engine has found a suspicous assignment of a value to a property. This check raises an issue when a value that can be of a given class or a super-class is assigned to a property that is type hinted more strictly.

Either this assignment is in error or an instanceof check should be added for that assignment.

class Alien {}

class Dalek extends Alien {}

class Plot
{
    /** @var  Dalek */
    public $villain;
}

$alien = new Alien();
$plot = new Plot();
if ($alien instanceof Dalek) {
    $plot->villain = $alien;
}
Loading history...
130
        $this->slugConverter = $slugConverter;
131
        $this->contentGateway = $contentGateway;
132
        $this->maskGenerator = $maskGenerator;
133
        $this->transactionHandler = $transactionHandler;
134
    }
135
136
    public function publishUrlAliasForLocation(
137
        $locationId,
138
        $parentLocationId,
139
        $name,
140
        $languageCode,
141
        $alwaysAvailable = false,
142
        $updatePathIdentificationString = false
143
    ) {
144
        $languageId = $this->languageHandler->loadByLanguageCode($languageCode)->id;
145
146
        $this->internalPublishUrlAliasForLocation(
147
            $locationId,
148
            $parentLocationId,
149
            $name,
150
            $languageId,
151
            $alwaysAvailable,
152
            $updatePathIdentificationString
153
        );
154
    }
155
156
    /**
157
     * Internal publish method, accepting language ID instead of language code and optionally
158
     * new alias ID (used when swapping Locations).
159
     *
160
     * @see \eZ\Publish\Core\Persistence\Legacy\Content\UrlAlias\Handler::locationSwapped()
161
     *
162
     * @param int $locationId
163
     * @param int $parentLocationId
164
     * @param string $name
165
     * @param int $languageId
166
     * @param bool $alwaysAvailable
167
     * @param bool $updatePathIdentificationString legacy storage specific for updating ezcontentobject_tree.path_identification_string
168
     * @param int $newId
169
     */
170
    private function internalPublishUrlAliasForLocation(
171
        $locationId,
172
        $parentLocationId,
173
        $name,
174
        $languageId,
175
        $alwaysAvailable = false,
176
        $updatePathIdentificationString = false,
177
        $newId = null
178
    ) {
179
        $parentId = $this->getRealAliasId($parentLocationId);
180
        $name = $this->slugConverter->convert($name, 'location_' . $locationId);
181
        $uniqueCounter = $this->slugConverter->getUniqueCounterValue($name, $parentId == 0);
182
        $languageMask = $languageId | (int)$alwaysAvailable;
183
        $action = 'eznode:' . $locationId;
184
        $cleanup = false;
185
186
        // Exiting the loop with break;
187
        while (true) {
188
            $newText = '';
189
            if ($locationId != self::CONTENT_REPOSITORY_ROOT_LOCATION_ID) {
0 ignored issues
show
Deprecated Code introduced by
The constant eZ\Publish\Core\Persiste...SITORY_ROOT_LOCATION_ID has been deprecated.

This class constant has been deprecated.

Loading history...
190
                $newText = $name . ($uniqueCounter > 1 ? $uniqueCounter : '');
191
            }
192
            $newTextMD5 = $this->getHash($newText);
193
194
            // Try to load existing entry
195
            $row = $this->gateway->loadRow($parentId, $newTextMD5);
196
197
            // If nothing was returned insert new entry
198
            if (empty($row)) {
199
                // Check for existing active location entry on this level and reuse it's id
200
                $existingLocationEntry = $this->gateway->loadAutogeneratedEntry($action, $parentId);
201
                if (!empty($existingLocationEntry)) {
202
                    $cleanup = true;
203
                    $newId = $existingLocationEntry['id'];
204
                }
205
206
                try {
207
                    $newId = $this->gateway->insertRow(
208
                        array(
209
                            'id' => $newId,
210
                            'link' => $newId,
211
                            'parent' => $parentId,
212
                            'action' => $action,
213
                            'lang_mask' => $languageMask,
214
                            'text' => $newText,
215
                            'text_md5' => $newTextMD5,
216
                        )
217
                    );
218
                } catch (\RuntimeException $e) {
219
                    while ($e->getPrevious() !== null) {
220
                        $e = $e->getPrevious();
221
                        if ($e instanceof UniqueConstraintViolationException) {
222
                            // Concurrency! someone else inserted the same row that we where going to.
223
                            // let's do another loop pass
224
                            $uniqueCounter += 1;
225
                            continue 2;
226
                        }
227
                    }
228
229
                    throw $e;
230
                }
231
232
                break;
233
            }
234
235
            // Row exists, check if it is reusable. There are 3 cases when this is possible:
236
            // 1. NOP entry
237
            // 2. existing location or custom alias entry
238
            // 3. history entry
239
            if ($row['action'] == 'nop:' || $row['action'] == $action || $row['is_original'] == 0) {
240
                // Check for existing location entry on this level, if it exists and it's id differs from reusable
241
                // entry id then reusable entry should be updated with the existing location entry id.
242
                // Note: existing location entry may be downgraded and relinked later, depending on its language.
243
                $existingLocationEntry = $this->gateway->loadAutogeneratedEntry($action, $parentId);
244
245
                if (!empty($existingLocationEntry)) {
246
                    // Always cleanup when active autogenerated entry exists on the same level
247
                    $cleanup = true;
248
                    $newId = $existingLocationEntry['id'];
249
                    if ($existingLocationEntry['id'] == $row['id']) {
250
                        // If we are reusing existing location entry merge existing language mask
251
                        $languageMask |= ($row['lang_mask'] & ~1);
252
                    }
253
                } elseif ($newId === null) {
254
                    // Use reused row ID only if publishing normally, else use given $newId
255
                    $newId = $row['id'];
256
                }
257
258
                $this->gateway->updateRow(
259
                    $parentId,
260
                    $newTextMD5,
261
                    array(
262
                        'action' => $action,
263
                        // In case when NOP row was reused
264
                        'action_type' => 'eznode',
265
                        'lang_mask' => $languageMask,
266
                        // Updating text ensures that letter case changes are stored
267
                        'text' => $newText,
268
                        // Set "id" and "link" for case when reusable entry is history
269
                        'id' => $newId,
270
                        'link' => $newId,
271
                        // Entry should be active location entry (original and not alias).
272
                        // Note: this takes care of taking over custom alias entry for the location on the same level
273
                        // and with same name and action.
274
                        'alias_redirects' => 1,
275
                        'is_original' => 1,
276
                        'is_alias' => 0,
277
                    )
278
                );
279
280
                break;
281
            }
282
283
            // If existing row is not reusable, increment $uniqueCounter and try again
284
            $uniqueCounter += 1;
285
        }
286
287
        /* @var $newText */
288
        if ($updatePathIdentificationString) {
289
            $this->locationGateway->updatePathIdentificationString(
290
                $locationId,
291
                $parentLocationId,
292
                $this->slugConverter->convert($newText, 'node_' . $locationId, 'urlalias_compat')
0 ignored issues
show
Bug introduced by
The variable $newText does not seem to be defined for all execution paths leading up to this point.

If you define a variable conditionally, it can happen that it is not defined for all execution paths.

Let’s take a look at an example:

function myFunction($a) {
    switch ($a) {
        case 'foo':
            $x = 1;
            break;

        case 'bar':
            $x = 2;
            break;
    }

    // $x is potentially undefined here.
    echo $x;
}

In the above example, the variable $x is defined if you pass “foo” or “bar” as argument for $a. However, since the switch statement has no default case statement, if you pass any other value, the variable $x would be undefined.

Available Fixes

  1. Check for existence of the variable explicitly:

    function myFunction($a) {
        switch ($a) {
            case 'foo':
                $x = 1;
                break;
    
            case 'bar':
                $x = 2;
                break;
        }
    
        if (isset($x)) { // Make sure it's always set.
            echo $x;
        }
    }
    
  2. Define a default value for the variable:

    function myFunction($a) {
        $x = ''; // Set a default which gets overridden for certain paths.
        switch ($a) {
            case 'foo':
                $x = 1;
                break;
    
            case 'bar':
                $x = 2;
                break;
        }
    
        echo $x;
    }
    
  3. Add a value for the missing path:

    function myFunction($a) {
        switch ($a) {
            case 'foo':
                $x = 1;
                break;
    
            case 'bar':
                $x = 2;
                break;
    
            // We add support for the missing case.
            default:
                $x = '';
                break;
        }
    
        echo $x;
    }
    
Loading history...
293
            );
294
        }
295
296
        /* @var $newId */
297
        /* @var $newTextMD5 */
298
        // Note: cleanup does not touch custom and global entries
299
        if ($cleanup) {
300
            $this->gateway->cleanupAfterPublish($action, $languageId, $newId, $parentId, $newTextMD5);
0 ignored issues
show
Bug introduced by
The variable $newTextMD5 does not seem to be defined for all execution paths leading up to this point.

If you define a variable conditionally, it can happen that it is not defined for all execution paths.

Let’s take a look at an example:

function myFunction($a) {
    switch ($a) {
        case 'foo':
            $x = 1;
            break;

        case 'bar':
            $x = 2;
            break;
    }

    // $x is potentially undefined here.
    echo $x;
}

In the above example, the variable $x is defined if you pass “foo” or “bar” as argument for $a. However, since the switch statement has no default case statement, if you pass any other value, the variable $x would be undefined.

Available Fixes

  1. Check for existence of the variable explicitly:

    function myFunction($a) {
        switch ($a) {
            case 'foo':
                $x = 1;
                break;
    
            case 'bar':
                $x = 2;
                break;
        }
    
        if (isset($x)) { // Make sure it's always set.
            echo $x;
        }
    }
    
  2. Define a default value for the variable:

    function myFunction($a) {
        $x = ''; // Set a default which gets overridden for certain paths.
        switch ($a) {
            case 'foo':
                $x = 1;
                break;
    
            case 'bar':
                $x = 2;
                break;
        }
    
        echo $x;
    }
    
  3. Add a value for the missing path:

    function myFunction($a) {
        switch ($a) {
            case 'foo':
                $x = 1;
                break;
    
            case 'bar':
                $x = 2;
                break;
    
            // We add support for the missing case.
            default:
                $x = '';
                break;
        }
    
        echo $x;
    }
    
Loading history...
301
        }
302
    }
303
304
    /**
305
     * Create a user chosen $alias pointing to $locationId in $languageCode.
306
     *
307
     * If $languageCode is null the $alias is created in the system's default
308
     * language. $alwaysAvailable makes the alias available in all languages.
309
     *
310
     * @throws \eZ\Publish\API\Repository\Exceptions\BadStateException
311
     * @throws \eZ\Publish\API\Repository\Exceptions\NotFoundException
312
     * @throws \eZ\Publish\API\Repository\Exceptions\ForbiddenException
313
     *
314
     * @param mixed $locationId
315
     * @param string $path
316
     * @param bool $forwarding
317
     * @param string $languageCode
318
     * @param bool $alwaysAvailable
319
     *
320
     * @return \eZ\Publish\SPI\Persistence\Content\UrlAlias
321
     */
322
    public function createCustomUrlAlias($locationId, $path, $forwarding = false, $languageCode = null, $alwaysAvailable = false)
323
    {
324
        return $this->createUrlAlias(
325
            'eznode:' . $locationId,
326
            $path,
327
            $forwarding,
328
            $languageCode,
329
            $alwaysAvailable
330
        );
331
    }
332
333
    /**
334
     * Create a user chosen $alias pointing to a resource in $languageCode.
335
     * This method does not handle location resources - if a user enters a location target
336
     * the createCustomUrlAlias method has to be used.
337
     *
338
     * If $languageCode is null the $alias is created in the system's default
339
     * language. $alwaysAvailable makes the alias available in all languages.
340
     *
341
     * @throws \eZ\Publish\API\Repository\Exceptions\ForbiddenException if the path already exists for the given language
342
     * @throws \eZ\Publish\API\Repository\Exceptions\BadStateException if the path is broken
343
     * @throws \eZ\Publish\API\Repository\Exceptions\NotFoundException
344
     *
345
     * @param string $resource
346
     * @param string $path
347
     * @param bool $forwarding
348
     * @param string $languageCode
349
     * @param bool $alwaysAvailable
350
     *
351
     * @return \eZ\Publish\SPI\Persistence\Content\UrlAlias
352
     */
353
    public function createGlobalUrlAlias($resource, $path, $forwarding = false, $languageCode = null, $alwaysAvailable = false)
354
    {
355
        return $this->createUrlAlias(
356
            $resource,
357
            $path,
358
            $forwarding,
359
            $languageCode,
360
            $alwaysAvailable
361
        );
362
    }
363
364
    /**
365
     * Internal method for creating global or custom URL alias (these are handled in the same way).
366
     *
367
     * @throws \eZ\Publish\Core\Base\Exceptions\ForbiddenException if the path already exists for the given language
368
     * @throws \eZ\Publish\API\Repository\Exceptions\NotFoundException
369
     * @throws \eZ\Publish\API\Repository\Exceptions\BadStateException
370
     *
371
     * @param string $action
372
     * @param string $path
373
     * @param bool $forward
374
     * @param string|null $languageCode
375
     * @param bool $alwaysAvailable
376
     *
377
     * @return \eZ\Publish\SPI\Persistence\Content\UrlAlias
378
     */
379
    protected function createUrlAlias($action, $path, $forward, $languageCode, $alwaysAvailable)
380
    {
381
        $pathElements = explode('/', $path);
382
        $topElement = array_pop($pathElements);
383
        $languageId = $this->languageHandler->loadByLanguageCode($languageCode)->id;
384
        $parentId = 0;
385
386
        // Handle all path elements except topmost one
387
        $isPathNew = false;
388
        foreach ($pathElements as $level => $pathElement) {
389
            $pathElement = $this->slugConverter->convert($pathElement, 'noname' . ($level + 1));
390
            $pathElementMD5 = $this->getHash($pathElement);
391
            if (!$isPathNew) {
392
                $row = $this->gateway->loadRow($parentId, $pathElementMD5);
393
                if (empty($row)) {
394
                    $isPathNew = true;
395
                } else {
396
                    $parentId = $row['link'];
397
                }
398
            }
399
400
            if ($isPathNew) {
401
                $parentId = $this->insertNopEntry($parentId, $pathElement, $pathElementMD5);
402
            }
403
        }
404
405
        // Handle topmost path element
406
        $topElement = $this->slugConverter->convert($topElement, 'noname' . (count($pathElements) + 1));
407
408
        // If last (next to topmost) entry parent is special root entry we handle topmost entry as first level entry
409
        // That is why we need to reset $parentId to 0
410
        if ($parentId != 0 && $this->gateway->isRootEntry($parentId)) {
411
            $parentId = 0;
412
        }
413
414
        $topElementMD5 = $this->getHash($topElement);
415
        // Set common values for two cases below
416
        $data = array(
417
            'action' => $action,
418
            'is_alias' => 1,
419
            'alias_redirects' => $forward ? 1 : 0,
420
            'parent' => $parentId,
421
            'text' => $topElement,
422
            'text_md5' => $topElementMD5,
423
            'is_original' => 1,
424
        );
425
        // Try to load topmost element
426
        if (!$isPathNew) {
427
            $row = $this->gateway->loadRow($parentId, $topElementMD5);
428
        }
429
430
        // If nothing was returned perform insert
431
        if ($isPathNew || empty($row)) {
432
            $data['lang_mask'] = $languageId | (int)$alwaysAvailable;
433
            $id = $this->gateway->insertRow($data);
434
        } elseif ($row['action'] == 'nop:' || $row['is_original'] == 0) {
435
            // Row exists, check if it is reusable. There are 2 cases when this is possible:
436
            // 1. NOP entry
437
            // 2. history entry
438
            $data['lang_mask'] = $languageId | (int)$alwaysAvailable;
439
            // If history is reused move link to id
440
            $data['link'] = $id = $row['id'];
441
            $this->gateway->updateRow(
442
                $parentId,
443
                $topElementMD5,
444
                $data
445
            );
446
        } else {
447
            throw new ForbiddenException("Path '%path%' already exists for the given language", ['%path%' => $path]);
448
        }
449
450
        $data['raw_path_data'] = $this->gateway->loadPathData($id);
451
452
        return $this->mapper->extractUrlAliasFromData($data);
453
    }
454
455
    /**
456
     * Convenience method for inserting nop type row.
457
     *
458
     * @param mixed $parentId
459
     * @param string $text
460
     * @param string $textMD5
461
     *
462
     * @return mixed
463
     */
464
    protected function insertNopEntry($parentId, $text, $textMD5)
465
    {
466
        return $this->gateway->insertRow(
467
            array(
468
                'lang_mask' => 1,
469
                'action' => 'nop:',
470
                'parent' => $parentId,
471
                'text' => $text,
472
                'text_md5' => $textMD5,
473
            )
474
        );
475
    }
476
477
    /**
478
     * List of user generated or autogenerated url entries, pointing to $locationId.
479
     *
480
     * @throws \eZ\Publish\API\Repository\Exceptions\BadStateException
481
     *
482
     * @param mixed $locationId
483
     * @param bool $custom if true the user generated aliases are listed otherwise the autogenerated
484
     *
485
     * @return \eZ\Publish\SPI\Persistence\Content\UrlAlias[]
486
     */
487 View Code Duplication
    public function listURLAliasesForLocation($locationId, $custom = false)
488
    {
489
        $data = $this->gateway->loadLocationEntries($locationId, $custom);
490
        foreach ($data as &$entry) {
491
            $entry['raw_path_data'] = $this->gateway->loadPathData($entry['id']);
492
        }
493
494
        return $this->mapper->extractUrlAliasListFromData($data);
495
    }
496
497
    /**
498
     * List global aliases.
499
     *
500
     * @throws \eZ\Publish\API\Repository\Exceptions\BadStateException
501
     *
502
     * @param string|null $languageCode
503
     * @param int $offset
504
     * @param int $limit
505
     *
506
     * @return \eZ\Publish\SPI\Persistence\Content\UrlAlias[]
507
     */
508 View Code Duplication
    public function listGlobalURLAliases($languageCode = null, $offset = 0, $limit = -1)
509
    {
510
        $data = $this->gateway->listGlobalEntries($languageCode, $offset, $limit);
511
        foreach ($data as &$entry) {
512
            $entry['raw_path_data'] = $this->gateway->loadPathData($entry['id']);
513
        }
514
515
        return $this->mapper->extractUrlAliasListFromData($data);
516
    }
517
518
    /**
519
     * Removes url aliases.
520
     *
521
     * Autogenerated aliases are not removed by this method.
522
     *
523
     * @param \eZ\Publish\SPI\Persistence\Content\UrlAlias[] $urlAliases
524
     *
525
     * @return bool
526
     */
527
    public function removeURLAliases(array $urlAliases)
528
    {
529
        foreach ($urlAliases as $urlAlias) {
530
            if ($urlAlias->isCustom) {
531
                list($parentId, $textMD5) = explode('-', $urlAlias->id);
532
                if (!$this->gateway->removeCustomAlias($parentId, $textMD5)) {
533
                    return false;
534
                }
535
            }
536
        }
537
538
        return true;
539
    }
540
541
    /**
542
     * Looks up a url alias for the given url.
543
     *
544
     * @throws \eZ\Publish\API\Repository\Exceptions\NotFoundException
545
     * @throws \eZ\Publish\API\Repository\Exceptions\InvalidArgumentException
546
     * @throws \eZ\Publish\API\Repository\Exceptions\BadStateException
547
     *
548
     * @param string $url
549
     *
550
     * @return \eZ\Publish\SPI\Persistence\Content\UrlAlias
551
     */
552
    public function lookup($url)
553
    {
554
        $urlHashes = array();
555
        foreach (explode('/', $url) as $level => $text) {
556
            $urlHashes[$level] = $this->getHash($text);
557
        }
558
559
        $pathDepth = count($urlHashes);
560
        if ($pathDepth > self::MAX_URL_ALIAS_DEPTH_LEVEL) {
561
            throw new InvalidArgumentException('$urlHashes', 'Exceeded maximum depth level of content url alias.');
562
        }
563
564
        $data = $this->gateway->loadUrlAliasData($urlHashes);
565
        if (empty($data)) {
566
            throw new NotFoundException('URLAlias', $url);
567
        }
568
569
        $hierarchyData = array();
570
        $isPathHistory = false;
571
        for ($level = 0; $level < $pathDepth; ++$level) {
572
            $prefix = $level === $pathDepth - 1 ? '' : 'ezurlalias_ml' . $level . '_';
573
            $isPathHistory = $isPathHistory ?: ($data[$prefix . 'link'] != $data[$prefix . 'id']);
574
            $hierarchyData[$level] = array(
575
                'id' => $data[$prefix . 'id'],
576
                'parent' => $data[$prefix . 'parent'],
577
                'action' => $data[$prefix . 'action'],
578
            );
579
        }
580
581
        $data['is_path_history'] = $isPathHistory;
582
        $data['raw_path_data'] = ($data['action_type'] == 'eznode' && !$data['is_alias'])
583
            ? $this->gateway->loadPathDataByHierarchy($hierarchyData)
584
            : $this->gateway->loadPathData($data['id']);
585
586
        return $this->mapper->extractUrlAliasFromData($data);
587
    }
588
589
    /**
590
     * Loads URL alias by given $id.
591
     *
592
     * @throws \eZ\Publish\API\Repository\Exceptions\NotFoundException
593
     * @throws \eZ\Publish\API\Repository\Exceptions\BadStateException
594
     *
595
     * @param string $id
596
     *
597
     * @return \eZ\Publish\SPI\Persistence\Content\UrlAlias
598
     */
599
    public function loadUrlAlias($id)
600
    {
601
        list($parentId, $textMD5) = explode('-', $id);
602
        $data = $this->gateway->loadRow($parentId, $textMD5);
603
604
        if (empty($data)) {
605
            throw new NotFoundException('URLAlias', $id);
606
        }
607
608
        $data['raw_path_data'] = $this->gateway->loadPathData($data['id']);
609
610
        return $this->mapper->extractUrlAliasFromData($data);
611
    }
612
613
    /**
614
     * Notifies the underlying engine that a location has moved.
615
     *
616
     * This method triggers the change of the autogenerated aliases.
617
     *
618
     * @param mixed $locationId
619
     * @param mixed $oldParentId
620
     * @param mixed $newParentId
621
     */
622
    public function locationMoved($locationId, $oldParentId, $newParentId)
623
    {
624
        // @todo optimize: $newLocationAliasId is already available in self::publishUrlAliasForLocation() as $newId
625
        $newParentLocationAliasId = $this->getRealAliasId($newParentId);
626
        $newLocationAlias = $this->gateway->loadAutogeneratedEntry(
627
            'eznode:' . $locationId,
628
            $newParentLocationAliasId
629
        );
630
631
        $oldParentLocationAliasId = $this->getRealAliasId($oldParentId);
632
        $oldLocationAlias = $this->gateway->loadAutogeneratedEntry(
633
            'eznode:' . $locationId,
634
            $oldParentLocationAliasId
635
        );
636
637
        // Historize alias for old location
638
        $this->gateway->historizeId($oldLocationAlias['id'], $newLocationAlias['id']);
639
        // Reparent subtree of old location to new location
640
        $this->gateway->reparent($oldLocationAlias['id'], $newLocationAlias['id']);
641
    }
642
643
    /**
644
     * Notifies the underlying engine that a location was copied.
645
     *
646
     * This method triggers the creation of the autogenerated aliases for the copied locations
647
     *
648
     * @param mixed $locationId
649
     * @param mixed $newLocationId
650
     * @param mixed $newParentId
651
     */
652
    public function locationCopied($locationId, $newLocationId, $newParentId)
653
    {
654
        $newParentAliasId = $this->getRealAliasId($newLocationId);
655
        $oldParentAliasId = $this->getRealAliasId($locationId);
656
657
        $actionMap = $this->getCopiedLocationsMap($locationId, $newLocationId);
658
659
        $this->copySubtree(
660
            $actionMap,
661
            $oldParentAliasId,
662
            $newParentAliasId
663
        );
664
    }
665
666
    /**
667
     * Notify the underlying engine that a Location has been swapped.
668
     *
669
     * This method triggers the change of the autogenerated aliases.
670
     *
671
     * @param int $location1Id
672
     * @param int $location1ParentId
673
     * @param int $location2Id
674
     * @param int $location2ParentId
675
     */
676
    public function locationSwapped($location1Id, $location1ParentId, $location2Id, $location2ParentId)
677
    {
678
        $location1 = new SwappedLocationProperties($location1Id, $location1ParentId);
679
        $location2 = new SwappedLocationProperties($location2Id, $location2ParentId);
680
681
        $location1->entries = $this->gateway->loadLocationEntries($location1Id);
682
        $location2->entries = $this->gateway->loadLocationEntries($location2Id);
683
684
        $location1->mainLanguageId = $this->gateway->getLocationContentMainLanguageId($location1Id);
685
        $location2->mainLanguageId = $this->gateway->getLocationContentMainLanguageId($location2Id);
686
687
        // Load autogenerated entries to find alias ID
688
        $location1->autogeneratedId = $this->gateway->loadAutogeneratedEntry("eznode:{$location1Id}")['id'];
689
        $location2->autogeneratedId = $this->gateway->loadAutogeneratedEntry("eznode:{$location2Id}")['id'];
690
691
        $contentInfo1 = $this->contentGateway->loadContentInfoByLocationId($location1Id);
692
        $contentInfo2 = $this->contentGateway->loadContentInfoByLocationId($location2Id);
693
694
        $names1 = $this->getNamesForAllLanguages($contentInfo1);
695
        $names2 = $this->getNamesForAllLanguages($contentInfo2);
696
697
        $location1->isAlwaysAvailable = $this->maskGenerator->isAlwaysAvailable($contentInfo1['language_mask']);
698
        $location2->isAlwaysAvailable = $this->maskGenerator->isAlwaysAvailable($contentInfo2['language_mask']);
699
700
        $languages = $this->languageHandler->loadAll();
701
702
        // Historize everything first to avoid name conflicts in case swapped Locations are siblings
703
        $this->historizeBeforeSwap($location1->entries, $location2->entries);
704
705
        foreach ($languages as $languageCode => $language) {
706
            $location1->name = isset($names1[$languageCode]) ? $names1[$languageCode] : null;
707
            $location2->name = isset($names2[$languageCode]) ? $names2[$languageCode] : null;
708
            $urlAliasesForSwappedLocations = $this->getUrlAliasesForSwappedLocations(
709
                $language,
710
                $location1,
711
                $location2
712
            );
713
            foreach ($urlAliasesForSwappedLocations as $urlAliasForLocation) {
714
                $this->internalPublishUrlAliasForLocation(
715
                    $urlAliasForLocation->id,
716
                    $urlAliasForLocation->parentId,
717
                    $urlAliasForLocation->name,
718
                    $language->id,
719
                    $urlAliasForLocation->isAlwaysAvailable,
720
                    $urlAliasForLocation->isPathIdentificationStringModified,
721
                    $urlAliasForLocation->newId
722
                );
723
            }
724
        }
725
    }
726
727
    /**
728
     * @param array $contentInfo
729
     *
730
     * @return array
731
     */
732
    private function getNamesForAllLanguages(array $contentInfo)
733
    {
734
        $nameDataArray = $this->contentGateway->loadVersionedNameData([
735
            [
736
                'id' => $contentInfo['id'],
737
                'version' => $contentInfo['current_version'],
738
            ],
739
        ]);
740
741
        $namesForAllLanguages = [];
742
        foreach ($nameDataArray as $nameData) {
743
            $namesForAllLanguages[$nameData['ezcontentobject_name_content_translation']]
744
                = $nameData['ezcontentobject_name_name'];
745
        }
746
747
        return $namesForAllLanguages;
748
    }
749
750
    /**
751
     * Historizes given existing active entries for two swapped Locations.
752
     *
753
     * This should be done before republishing URL aliases, in order to avoid unnecessary
754
     * conflicts when swapped Locations are siblings.
755
     *
756
     * We need to historize everything separately per language (mask), in case the entries
757
     * remain history future publishing reusages need to be able to take them over cleanly.
758
     *
759
     * @see \eZ\Publish\Core\Persistence\Legacy\Content\UrlAlias\Handler::locationSwapped()
760
     *
761
     * @param array $location1Entries
762
     * @param array $location2Entries
763
     */
764
    private function historizeBeforeSwap($location1Entries, $location2Entries)
765
    {
766
        foreach ($location1Entries as $row) {
767
            $this->gateway->historizeBeforeSwap($row['action'], $row['lang_mask']);
768
        }
769
770
        foreach ($location2Entries as $row) {
771
            $this->gateway->historizeBeforeSwap($row['action'], $row['lang_mask']);
772
        }
773
    }
774
775
    /**
776
     * Decides if UrlAlias for $location2 should be published first.
777
     *
778
     * The order in which Locations are published only matters if swapped Locations are siblings and they have the same
779
     * name in a given language. In this case, the UrlAlias for Location which previously had lower number at the end of
780
     * its UrlAlias text (or no number at all) should be published first. This ensures that the number still stays lower
781
     * for this Location after the swap. If it wouldn't stay lower, then swapping Locations in conjunction with swapping
782
     * UrlAliases would effectively cancel each other.
783
     *
784
     * @param array $location1Entries
785
     * @param int $location1ParentId
786
     * @param string $name1
787
     * @param array $location2Entries
788
     * @param int $location2ParentId
789
     * @param string $name2
790
     * @param int $languageId
791
     *
792
     * @return bool
793
     */
794
    private function shouldUrlAliasForSecondLocationBePublishedFirst(
795
        array $location1Entries,
796
        $location1ParentId,
797
        $name1,
798
        array $location2Entries,
799
        $location2ParentId,
800
        $name2,
801
        $languageId
802
    ) {
803
        if ($location1ParentId === $location2ParentId && $name1 === $name2) {
804
            $locationEntry1 = $this->getLocationEntryInLanguage($location1Entries, $languageId);
805
            $locationEntry2 = $this->getLocationEntryInLanguage($location2Entries, $languageId);
806
807
            if ($locationEntry1 === null || $locationEntry2 === null) {
808
                return false;
809
            }
810
811
            if ($locationEntry2['text'] < $locationEntry1['text']) {
812
                return true;
813
            }
814
        }
815
816
        return false;
817
    }
818
819
    /**
820
     * Get in a proper order - to be published - a list of URL aliases for swapped Locations.
821
     *
822
     * @see shouldUrlAliasForSecondLocationBePublishedFirst
823
     *
824
     * @param \eZ\Publish\SPI\Persistence\Content\Language $language
825
     * @param \eZ\Publish\Core\Persistence\Legacy\Content\UrlAlias\DTO\SwappedLocationProperties $location1
826
     * @param \eZ\Publish\Core\Persistence\Legacy\Content\UrlAlias\DTO\SwappedLocationProperties $location2
827
     *
828
     * @return \eZ\Publish\Core\Persistence\Legacy\Content\UrlAlias\DTO\UrlAliasForSwappedLocation[]
829
     */
830
    private function getUrlAliasesForSwappedLocations(
831
        Language $language,
832
        SwappedLocationProperties $location1,
833
        SwappedLocationProperties $location2
834
    ) {
835
        $isMainLanguage1 = $language->id == $location1->mainLanguageId;
836
        $isMainLanguage2 = $language->id == $location2->mainLanguageId;
837
        $urlAliases = [];
838
        if (isset($location1->name)) {
839
            $urlAliases[] = new UrlAliasForSwappedLocation(
840
                $location1->id,
841
                $location1->parentId,
842
                $location1->name,
843
                $isMainLanguage2 && $location1->isAlwaysAvailable,
844
                $isMainLanguage2,
845
                $location1->autogeneratedId
846
            );
847
        }
848
849
        if (isset($location2->name)) {
850
            $urlAliases[] = new UrlAliasForSwappedLocation(
851
                $location2->id,
852
                $location2->parentId,
853
                $location2->name,
854
                $isMainLanguage1 && $location2->isAlwaysAvailable,
855
                $isMainLanguage1,
856
                $location2->autogeneratedId
857
            );
858
859
            if (isset($location1->name) && $this->shouldUrlAliasForSecondLocationBePublishedFirst(
860
                    $location1->entries,
861
                    $location1->parentId,
862
                    $location1->name,
863
                    $location2->entries,
864
                    $location2->parentId,
865
                    $location2->name,
866
                    $language->id
867
                )) {
868
                $urlAliases = array_reverse($urlAliases);
869
            }
870
        }
871
872
        return $urlAliases;
873
    }
874
875
    /**
876
     * @param array $locationEntries
877
     * @param int $languageId
878
     *
879
     * @return array|null
880
     */
881
    private function getLocationEntryInLanguage(array $locationEntries, $languageId)
882
    {
883
        $entries = array_filter(
884
            $locationEntries,
885
            function (array $row) use ($languageId) {
886
                return (bool) ($row['lang_mask'] & $languageId);
887
            }
888
        );
889
890
        return !empty($entries) ? array_shift($entries) : null;
891
    }
892
893
    /**
894
     * Returns possibly corrected alias id for given $locationId !! For use as parent id in logic.
895
     *
896
     * First level entries must have parent id set to 0 instead of their parent location alias id.
897
     * There are two cases when alias id needs to be corrected:
898
     * 1) location is special location without URL alias (location with id=1 in standard installation)
899
     * 2) location is site root location, having special root entry in the ezurlalias_ml table (location with id=2
900
     *    in standard installation)
901
     *
902
     * @param mixed $locationId
903
     *
904
     * @return mixed
905
     */
906
    protected function getRealAliasId($locationId)
907
    {
908
        // Absolute root location does have a url alias entry so we can skip lookup
909
        if ($locationId == self::ROOT_LOCATION_ID) {
910
            return 0;
911
        }
912
913
        $data = $this->gateway->loadAutogeneratedEntry('eznode:' . $locationId);
914
915
        // Root entries (URL wise) can return 0 as the returned value is used as parent (parent is 0 for root entries)
916
        if (empty($data) || ($data['id'] != 0 && $data['parent'] == 0 && strlen($data['text']) == 0)) {
917
            $id = 0;
918
        } else {
919
            $id = $data['id'];
920
        }
921
922
        return $id;
923
    }
924
925
    /**
926
     * Recursively copies aliases from old parent under new parent.
927
     *
928
     * @param array $actionMap
929
     * @param mixed $oldParentAliasId
930
     * @param mixed $newParentAliasId
931
     */
932
    protected function copySubtree($actionMap, $oldParentAliasId, $newParentAliasId)
933
    {
934
        $rows = $this->gateway->loadAutogeneratedEntries($oldParentAliasId);
935
        $newIdsMap = array();
936
        foreach ($rows as $row) {
937
            $oldParentAliasId = $row['id'];
938
939
            // Ensure that same action entries remain grouped by the same id
940
            if (!isset($newIdsMap[$oldParentAliasId])) {
941
                $newIdsMap[$oldParentAliasId] = $this->gateway->getNextId();
942
            }
943
944
            $row['action'] = $actionMap[$row['action']];
945
            $row['parent'] = $newParentAliasId;
946
            $row['id'] = $row['link'] = $newIdsMap[$oldParentAliasId];
947
            $this->gateway->insertRow($row);
948
949
            $this->copySubtree(
950
                $actionMap,
951
                $oldParentAliasId,
952
                $row['id']
953
            );
954
        }
955
    }
956
957
    /**
958
     * @param mixed $oldParentId
959
     * @param mixed $newParentId
960
     *
961
     * @return array
962
     */
963
    protected function getCopiedLocationsMap($oldParentId, $newParentId)
964
    {
965
        $originalLocations = $this->locationGateway->getSubtreeContent($oldParentId);
966
        $copiedLocations = $this->locationGateway->getSubtreeContent($newParentId);
967
968
        $map = array();
969
        foreach ($originalLocations as $index => $originalLocation) {
970
            $map['eznode:' . $originalLocation['node_id']] = 'eznode:' . $copiedLocations[$index]['node_id'];
971
        }
972
973
        return $map;
974
    }
975
976
    /**
977
     * Notifies the underlying engine that a location was deleted or moved to trash.
978
     *
979
     * @param mixed $locationId
980
     */
981
    public function locationDeleted($locationId)
982
    {
983
        $action = 'eznode:' . $locationId;
984
        $entry = $this->gateway->loadAutogeneratedEntry($action);
985
986
        $this->removeSubtree($entry['id'], $action, $entry['is_original']);
987
    }
988
989
    /**
990
     * Notifies the underlying engine that Locations Content Translation was removed.
991
     *
992
     * @param int[] $locationIds all Locations of the Content that got Translation removed
993
     * @param string $languageCode language code of the removed Translation
994
     */
995
    public function translationRemoved(array $locationIds, $languageCode)
996
    {
997
        $languageId = $this->languageHandler->loadByLanguageCode($languageCode)->id;
998
999
        $actions = [];
1000
        foreach ($locationIds as $locationId) {
1001
            $actions[] = 'eznode:' . $locationId;
1002
        }
1003
        $this->gateway->bulkRemoveTranslation($languageId, $actions);
1004
    }
1005
1006
    /**
1007
     * Recursively removes aliases by given $id and $action.
1008
     *
1009
     * $original parameter is used to limit removal of moved Location aliases to history entries only.
1010
     *
1011
     * @param mixed $id
1012
     * @param string $action
1013
     * @param mixed $original
1014
     */
1015
    protected function removeSubtree($id, $action, $original)
1016
    {
1017
        // Remove first to avoid unnecessary recursion.
1018
        if ($original) {
1019
            // If entry is original remove all for action (history and custom entries included).
1020
            $this->gateway->remove($action);
1021
        } else {
1022
            // Else entry is history, so remove only for action with the id.
1023
            // This means $id grouped history entries are removed, other history, active autogenerated
1024
            // and custom are left alone.
1025
            $this->gateway->remove($action, $id);
1026
        }
1027
1028
        // Load all autogenerated for parent $id, including history.
1029
        $entries = $this->gateway->loadAutogeneratedEntries($id, true);
1030
1031
        foreach ($entries as $entry) {
1032
            $this->removeSubtree($entry['id'], $entry['action'], $entry['is_original']);
1033
        }
1034
    }
1035
1036
    /**
1037
     * @param string $text
1038
     *
1039
     * @return string
1040
     */
1041
    protected function getHash($text)
1042
    {
1043
        return md5(mb_strtolower($text, 'UTF-8'));
1044
    }
1045
1046
    /**
1047
     * {@inheritdoc}
1048
     */
1049
    public function archiveUrlAliasesForDeletedTranslations($locationId, $parentLocationId, array $languageCodes)
1050
    {
1051
        $parentId = $this->getRealAliasId($parentLocationId);
1052
1053
        $data = $this->gateway->loadLocationEntries($locationId);
1054
        // filter removed Translations
1055
        $removedLanguages = array_diff(
1056
            $this->mapper->extractLanguageCodesFromData($data),
1057
            $languageCodes
1058
        );
1059
1060
        if (empty($removedLanguages)) {
1061
            return;
1062
        }
1063
1064
        // map languageCodes to their IDs
1065
        $languageIds = array_map(
1066
            function ($languageCode) {
1067
                return $this->languageHandler->loadByLanguageCode($languageCode)->id;
1068
            },
1069
            $removedLanguages
1070
        );
1071
1072
        $this->gateway->archiveUrlAliasesForDeletedTranslations($locationId, $parentId, $languageIds);
1073
    }
1074
1075
    /**
1076
     * Remove corrupted URL aliases (global, custom and system).
1077
     *
1078
     * @return int Number of removed URL aliases
1079
     *
1080
     * @throws \Exception
1081
     */
1082
    public function deleteCorruptedUrlAliases()
1083
    {
1084
        $this->transactionHandler->beginTransaction();
1085
        try {
1086
            $totalCount = $this->gateway->deleteUrlAliasesWithoutLocation();
1087
            $totalCount += $this->gateway->deleteUrlAliasesWithoutParent();
1088
            $totalCount += $this->gateway->deleteUrlAliasesWithBrokenLink();
1089
1090
            $this->transactionHandler->commit();
1091
1092
            return $totalCount;
1093
        } catch (\Exception $e) {
1094
            $this->transactionHandler->rollback();
1095
            throw $e;
1096
        }
1097
    }
1098
1099
    /**
1100
     * Attempt repairing auto-generated URL aliases for the given Location (including history).
1101
     *
1102
     * Note: it is assumed that at this point original, working, URL Alias for Location is published.
1103
     *
1104
     * @param int $locationId
1105
     *
1106
     * @throws \eZ\Publish\Core\Base\Exceptions\BadStateException
1107
     */
1108
    public function repairBrokenUrlAliasesForLocation(int $locationId)
1109
    {
1110
        try {
1111
            $this->gateway->repairBrokenUrlAliasesForLocation($locationId);
1112
        } catch (\RuntimeException $e) {
1113
            throw new BadStateException('locationId', $e->getMessage(), $e);
1114
        }
1115
    }
1116
}
1117