Completed
Push — master ( 6fba66...640c23 )
by
unknown
32:41 queued 12:46
created

Handler::getNamesForAllLanguages()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 17
Code Lines 9

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
nc 2
dl 0
loc 17
c 0
b 0
f 0
cc 2
eloc 9
nop 1
rs 9.4285
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\InvalidArgumentException;
12
use eZ\Publish\Core\Persistence\Legacy\Content\Language\MaskGenerator;
13
use eZ\Publish\Core\Persistence\Legacy\Content\UrlAlias\DTO\SwappedLocationProperties;
14
use eZ\Publish\Core\Persistence\Legacy\Content\UrlAlias\DTO\UrlAliasForSwappedLocation;
15
use eZ\Publish\SPI\Persistence\Content\Language;
16
use eZ\Publish\SPI\Persistence\Content\UrlAlias;
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
25
/**
26
 * The UrlAlias Handler provides nice urls management.
27
 *
28
 * Its methods operate on a representation of the url alias data structure held
29
 * inside a storage engine.
30
 */
31
class Handler implements UrlAliasHandlerInterface
32
{
33
    const ROOT_LOCATION_ID = 1;
34
35
    /**
36
     * This is intentionally hardcoded for now as:
37
     * 1. We don't implement this configuration option.
38
     * 2. Such option should not be in this layer, should be handled higher up.
39
     *
40
     * @deprecated
41
     */
42
    const CONTENT_REPOSITORY_ROOT_LOCATION_ID = 2;
43
44
    /**
45
     * The maximum level of alias depth.
46
     */
47
    const MAX_URL_ALIAS_DEPTH_LEVEL = 60;
48
49
    /**
50
     * UrlAlias Gateway.
51
     *
52
     * @var \eZ\Publish\Core\Persistence\Legacy\Content\UrlAlias\Gateway
53
     */
54
    protected $gateway;
55
56
    /**
57
     * Gateway for handling location data.
58
     *
59
     * @var \eZ\Publish\Core\Persistence\Legacy\Content\Location\Gateway
60
     */
61
    protected $locationGateway;
62
63
    /**
64
     * UrlAlias Mapper.
65
     *
66
     * @var \eZ\Publish\Core\Persistence\Legacy\Content\UrlAlias\Mapper
67
     */
68
    protected $mapper;
69
70
    /**
71
     * Caching language handler.
72
     *
73
     * @var \eZ\Publish\Core\Persistence\Legacy\Content\Language\CachingHandler
74
     */
75
    protected $languageHandler;
76
77
    /**
78
     * URL slug converter.
79
     *
80
     * @var \eZ\Publish\Core\Persistence\Legacy\Content\UrlAlias\SlugConverter
81
     */
82
    protected $slugConverter;
83
84
    /**
85
     * Gateway for handling content data.
86
     *
87
     * @var \eZ\Publish\Core\Persistence\Legacy\Content\Gateway
88
     */
89
    protected $contentGateway;
90
91
    /**
92
     * Language mask generator.
93
     *
94
     * @var \eZ\Publish\Core\Persistence\Legacy\Content\Language\MaskGenerator
95
     */
96
    protected $maskGenerator;
97
98
    /**
99
     * Creates a new UrlAlias Handler.
100
     *
101
     * @param \eZ\Publish\Core\Persistence\Legacy\Content\UrlAlias\Gateway $gateway
102
     * @param \eZ\Publish\Core\Persistence\Legacy\Content\UrlAlias\Mapper $mapper
103
     * @param \eZ\Publish\Core\Persistence\Legacy\Content\Location\Gateway $locationGateway
104
     * @param \eZ\Publish\SPI\Persistence\Content\Language\Handler $languageHandler
105
     * @param \eZ\Publish\Core\Persistence\Legacy\Content\UrlAlias\SlugConverter $slugConverter
106
     * @param \eZ\Publish\Core\Persistence\Legacy\Content\Gateway $contentGateway
107
     * @param \eZ\Publish\Core\Persistence\Legacy\Content\Language\MaskGenerator $maskGenerator
108
     */
109 View Code Duplication
    public function __construct(
110
        Gateway $gateway,
111
        Mapper $mapper,
112
        LocationGateway $locationGateway,
113
        LanguageHandler $languageHandler,
114
        SlugConverter $slugConverter,
115
        ContentGateway $contentGateway,
116
        MaskGenerator $maskGenerator
117
    ) {
118
        $this->gateway = $gateway;
119
        $this->mapper = $mapper;
120
        $this->locationGateway = $locationGateway;
121
        $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...
122
        $this->slugConverter = $slugConverter;
123
        $this->contentGateway = $contentGateway;
124
        $this->maskGenerator = $maskGenerator;
125
    }
126
127
    public function publishUrlAliasForLocation(
128
        $locationId,
129
        $parentLocationId,
130
        $name,
131
        $languageCode,
132
        $alwaysAvailable = false,
133
        $updatePathIdentificationString = false
134
    ) {
135
        $languageId = $this->languageHandler->loadByLanguageCode($languageCode)->id;
136
137
        $this->internalPublishUrlAliasForLocation(
138
            $locationId,
139
            $parentLocationId,
140
            $name,
141
            $languageId,
142
            $alwaysAvailable,
143
            $updatePathIdentificationString
144
        );
145
    }
146
147
    /**
148
     * Internal publish method, accepting language ID instead of language code and optionally
149
     * new alias ID (used when swapping Locations).
150
     *
151
     * @see \eZ\Publish\Core\Persistence\Legacy\Content\UrlAlias\Handler::locationSwapped()
152
     *
153
     * @param int $locationId
154
     * @param int $parentLocationId
155
     * @param string $name
156
     * @param int $languageId
157
     * @param bool $alwaysAvailable
158
     * @param bool $updatePathIdentificationString legacy storage specific for updating ezcontentobject_tree.path_identification_string
159
     * @param int $newId
160
     */
161
    private function internalPublishUrlAliasForLocation(
162
        $locationId,
163
        $parentLocationId,
164
        $name,
165
        $languageId,
166
        $alwaysAvailable = false,
167
        $updatePathIdentificationString = false,
168
        $newId = null
169
    ) {
170
        $parentId = $this->getRealAliasId($parentLocationId);
171
        $name = $this->slugConverter->convert($name, 'location_' . $locationId);
172
        $uniqueCounter = $this->slugConverter->getUniqueCounterValue($name, $parentId == 0);
173
        $languageMask = $languageId | (int)$alwaysAvailable;
174
        $action = 'eznode:' . $locationId;
175
        $cleanup = false;
176
177
        // Exiting the loop with break;
178
        while (true) {
179
            $newText = '';
180
            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...
181
                $newText = $name . ($uniqueCounter > 1 ? $uniqueCounter : '');
182
            }
183
            $newTextMD5 = $this->getHash($newText);
184
185
            // Try to load existing entry
186
            $row = $this->gateway->loadRow($parentId, $newTextMD5);
187
188
            // If nothing was returned insert new entry
189
            if (empty($row)) {
190
                // Check for existing active location entry on this level and reuse it's id
191
                $existingLocationEntry = $this->gateway->loadAutogeneratedEntry($action, $parentId);
192
                if (!empty($existingLocationEntry)) {
193
                    $cleanup = true;
194
                    $newId = $existingLocationEntry['id'];
195
                }
196
197
                try {
198
                    $newId = $this->gateway->insertRow(
199
                        array(
200
                            'id' => $newId,
201
                            'link' => $newId,
202
                            'parent' => $parentId,
203
                            'action' => $action,
204
                            'lang_mask' => $languageMask,
205
                            'text' => $newText,
206
                            'text_md5' => $newTextMD5,
207
                        )
208
                    );
209
                } catch (\RuntimeException $e) {
210
                    while ($e->getPrevious() !== null) {
211
                        $e = $e->getPrevious();
212
                        if ($e instanceof UniqueConstraintViolationException) {
213
                            // Concurrency! someone else inserted the same row that we where going to.
214
                            // let's do another loop pass
215
                            $uniqueCounter += 1;
216
                            continue 2;
217
                        }
218
                    }
219
220
                    throw $e;
221
                }
222
223
                break;
224
            }
225
226
            // Row exists, check if it is reusable. There are 3 cases when this is possible:
227
            // 1. NOP entry
228
            // 2. existing location or custom alias entry
229
            // 3. history entry
230
            if ($row['action'] == 'nop:' || $row['action'] == $action || $row['is_original'] == 0) {
231
                // Check for existing location entry on this level, if it exists and it's id differs from reusable
232
                // entry id then reusable entry should be updated with the existing location entry id.
233
                // Note: existing location entry may be downgraded and relinked later, depending on its language.
234
                $existingLocationEntry = $this->gateway->loadAutogeneratedEntry($action, $parentId);
235
236
                if (!empty($existingLocationEntry)) {
237
                    // Always cleanup when active autogenerated entry exists on the same level
238
                    $cleanup = true;
239
                    $newId = $existingLocationEntry['id'];
240
                    if ($existingLocationEntry['id'] == $row['id']) {
241
                        // If we are reusing existing location entry merge existing language mask
242
                        $languageMask |= ($row['lang_mask'] & ~1);
243
                    }
244
                } elseif ($newId === null) {
245
                    // Use reused row ID only if publishing normally, else use given $newId
246
                    $newId = $row['id'];
247
                }
248
249
                $this->gateway->updateRow(
250
                    $parentId,
251
                    $newTextMD5,
252
                    array(
253
                        'action' => $action,
254
                        // In case when NOP row was reused
255
                        'action_type' => 'eznode',
256
                        'lang_mask' => $languageMask,
257
                        // Updating text ensures that letter case changes are stored
258
                        'text' => $newText,
259
                        // Set "id" and "link" for case when reusable entry is history
260
                        'id' => $newId,
261
                        'link' => $newId,
262
                        // Entry should be active location entry (original and not alias).
263
                        // Note: this takes care of taking over custom alias entry for the location on the same level
264
                        // and with same name and action.
265
                        'alias_redirects' => 1,
266
                        'is_original' => 1,
267
                        'is_alias' => 0,
268
                    )
269
                );
270
271
                break;
272
            }
273
274
            // If existing row is not reusable, increment $uniqueCounter and try again
275
            $uniqueCounter += 1;
276
        }
277
278
        /* @var $newText */
279
        if ($updatePathIdentificationString) {
280
            $this->locationGateway->updatePathIdentificationString(
281
                $locationId,
282
                $parentLocationId,
283
                $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...
284
            );
285
        }
286
287
        /* @var $newId */
288
        /* @var $newTextMD5 */
289
        // Note: cleanup does not touch custom and global entries
290
        if ($cleanup) {
291
            $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...
292
        }
293
    }
294
295
    /**
296
     * Create a user chosen $alias pointing to $locationId in $languageCode.
297
     *
298
     * If $languageCode is null the $alias is created in the system's default
299
     * language. $alwaysAvailable makes the alias available in all languages.
300
     *
301
     * @param mixed $locationId
302
     * @param string $path
303
     * @param bool $forwarding
304
     * @param string $languageCode
305
     * @param bool $alwaysAvailable
306
     *
307
     * @return \eZ\Publish\SPI\Persistence\Content\UrlAlias
308
     */
309
    public function createCustomUrlAlias($locationId, $path, $forwarding = false, $languageCode = null, $alwaysAvailable = false)
310
    {
311
        return $this->createUrlAlias(
312
            'eznode:' . $locationId,
313
            $path,
314
            $forwarding,
315
            $languageCode,
316
            $alwaysAvailable
317
        );
318
    }
319
320
    /**
321
     * Create a user chosen $alias pointing to a resource in $languageCode.
322
     * This method does not handle location resources - if a user enters a location target
323
     * the createCustomUrlAlias method has to be used.
324
     *
325
     * If $languageCode is null the $alias is created in the system's default
326
     * language. $alwaysAvailable makes the alias available in all languages.
327
     *
328
     * @throws \eZ\Publish\API\Repository\Exceptions\ForbiddenException if the path already exists for the given language
329
     *
330
     * @param string $resource
331
     * @param string $path
332
     * @param bool $forwarding
333
     * @param string $languageCode
334
     * @param bool $alwaysAvailable
335
     *
336
     * @return \eZ\Publish\SPI\Persistence\Content\UrlAlias
337
     */
338
    public function createGlobalUrlAlias($resource, $path, $forwarding = false, $languageCode = null, $alwaysAvailable = false)
339
    {
340
        return $this->createUrlAlias(
341
            $resource,
342
            $path,
343
            $forwarding,
344
            $languageCode,
345
            $alwaysAvailable
346
        );
347
    }
348
349
    /**
350
     * Internal method for creating global or custom URL alias (these are handled in the same way).
351
     *
352
     * @throws \eZ\Publish\Core\Base\Exceptions\ForbiddenException if the path already exists for the given language
353
     *
354
     * @param string $action
355
     * @param string $path
356
     * @param bool $forward
357
     * @param string|null $languageCode
358
     * @param bool $alwaysAvailable
359
     *
360
     * @return \eZ\Publish\SPI\Persistence\Content\UrlAlias
361
     */
362
    protected function createUrlAlias($action, $path, $forward, $languageCode, $alwaysAvailable)
363
    {
364
        $pathElements = explode('/', $path);
365
        $topElement = array_pop($pathElements);
366
        $languageId = $this->languageHandler->loadByLanguageCode($languageCode)->id;
367
        $parentId = 0;
368
369
        // Handle all path elements except topmost one
370
        $isPathNew = false;
371
        foreach ($pathElements as $level => $pathElement) {
372
            $pathElement = $this->slugConverter->convert($pathElement, 'noname' . ($level + 1));
373
            $pathElementMD5 = $this->getHash($pathElement);
374
            if (!$isPathNew) {
375
                $row = $this->gateway->loadRow($parentId, $pathElementMD5);
376
                if (empty($row)) {
377
                    $isPathNew = true;
378
                } else {
379
                    $parentId = $row['link'];
380
                }
381
            }
382
383
            if ($isPathNew) {
384
                $parentId = $this->insertNopEntry($parentId, $pathElement, $pathElementMD5);
385
            }
386
        }
387
388
        // Handle topmost path element
389
        $topElement = $this->slugConverter->convert($topElement, 'noname' . (count($pathElements) + 1));
390
391
        // If last (next to topmost) entry parent is special root entry we handle topmost entry as first level entry
392
        // That is why we need to reset $parentId to 0
393
        if ($parentId != 0 && $this->gateway->isRootEntry($parentId)) {
394
            $parentId = 0;
395
        }
396
397
        $topElementMD5 = $this->getHash($topElement);
398
        // Set common values for two cases below
399
        $data = array(
400
            'action' => $action,
401
            'is_alias' => 1,
402
            'alias_redirects' => $forward ? 1 : 0,
403
            'parent' => $parentId,
404
            'text' => $topElement,
405
            'text_md5' => $topElementMD5,
406
            'is_original' => 1,
407
        );
408
        // Try to load topmost element
409
        if (!$isPathNew) {
410
            $row = $this->gateway->loadRow($parentId, $topElementMD5);
411
        }
412
413
        // If nothing was returned perform insert
414
        if ($isPathNew || empty($row)) {
415
            $data['lang_mask'] = $languageId | (int)$alwaysAvailable;
416
            $id = $this->gateway->insertRow($data);
417
        } elseif ($row['action'] == 'nop:' || $row['is_original'] == 0) {
418
            // Row exists, check if it is reusable. There are 2 cases when this is possible:
419
            // 1. NOP entry
420
            // 2. history entry
421
            $data['lang_mask'] = $languageId | (int)$alwaysAvailable;
422
            // If history is reused move link to id
423
            $data['link'] = $id = $row['id'];
424
            $this->gateway->updateRow(
425
                $parentId,
426
                $topElementMD5,
427
                $data
428
            );
429
        } else {
430
            throw new ForbiddenException("Path '%path%' already exists for the given language", ['%path%' => $path]);
431
        }
432
433
        $data['raw_path_data'] = $this->gateway->loadPathData($id);
434
435
        return $this->mapper->extractUrlAliasFromData($data);
436
    }
437
438
    /**
439
     * Convenience method for inserting nop type row.
440
     *
441
     * @param mixed $parentId
442
     * @param string $text
443
     * @param string $textMD5
444
     *
445
     * @return mixed
446
     */
447
    protected function insertNopEntry($parentId, $text, $textMD5)
448
    {
449
        return $this->gateway->insertRow(
450
            array(
451
                'lang_mask' => 1,
452
                'action' => 'nop:',
453
                'parent' => $parentId,
454
                'text' => $text,
455
                'text_md5' => $textMD5,
456
            )
457
        );
458
    }
459
460
    /**
461
     * List of user generated or autogenerated url entries, pointing to $locationId.
462
     *
463
     * @param mixed $locationId
464
     * @param bool $custom if true the user generated aliases are listed otherwise the autogenerated
465
     *
466
     * @return \eZ\Publish\SPI\Persistence\Content\UrlAlias[]
467
     */
468 View Code Duplication
    public function listURLAliasesForLocation($locationId, $custom = false)
469
    {
470
        $data = $this->gateway->loadLocationEntries($locationId, $custom);
471
        foreach ($data as &$entry) {
472
            $entry['raw_path_data'] = $this->gateway->loadPathData($entry['id']);
473
        }
474
475
        return $this->mapper->extractUrlAliasListFromData($data);
476
    }
477
478
    /**
479
     * List global aliases.
480
     *
481
     * @param string|null $languageCode
482
     * @param int $offset
483
     * @param int $limit
484
     *
485
     * @return \eZ\Publish\SPI\Persistence\Content\UrlAlias[]
486
     */
487 View Code Duplication
    public function listGlobalURLAliases($languageCode = null, $offset = 0, $limit = -1)
488
    {
489
        $data = $this->gateway->listGlobalEntries($languageCode, $offset, $limit);
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
     * Removes url aliases.
499
     *
500
     * Autogenerated aliases are not removed by this method.
501
     *
502
     * @param \eZ\Publish\SPI\Persistence\Content\UrlAlias[] $urlAliases
503
     *
504
     * @return bool
505
     */
506
    public function removeURLAliases(array $urlAliases)
507
    {
508
        foreach ($urlAliases as $urlAlias) {
509
            if ($urlAlias->isCustom) {
510
                list($parentId, $textMD5) = explode('-', $urlAlias->id);
511
                if (!$this->gateway->removeCustomAlias($parentId, $textMD5)) {
512
                    return false;
513
                }
514
            }
515
        }
516
517
        return true;
518
    }
519
520
    /**
521
     * Looks up a url alias for the given url.
522
     *
523
     * @throws \eZ\Publish\API\Repository\Exceptions\NotFoundException
524
     * @throws \RuntimeException
525
     * @throws \eZ\Publish\Core\Base\Exceptions\NotFoundException
526
     * @throws \eZ\Publish\Core\Base\Exceptions\InvalidArgumentException
527
     *
528
     * @param string $url
529
     *
530
     * @return \eZ\Publish\SPI\Persistence\Content\UrlAlias
531
     */
532
    public function lookup($url)
533
    {
534
        $urlHashes = array();
535
        foreach (explode('/', $url) as $level => $text) {
536
            $urlHashes[$level] = $this->getHash($text);
537
        }
538
539
        $pathDepth = count($urlHashes);
540
        if ($pathDepth > self::MAX_URL_ALIAS_DEPTH_LEVEL) {
541
            throw new InvalidArgumentException('$urlHashes', 'Exceeded maximum depth level of content url alias.');
542
        }
543
544
        $data = $this->gateway->loadUrlAliasData($urlHashes);
545
        if (empty($data)) {
546
            throw new NotFoundException('URLAlias', $url);
547
        }
548
549
        $hierarchyData = array();
550
        $isPathHistory = false;
551
        for ($level = 0; $level < $pathDepth; ++$level) {
552
            $prefix = $level === $pathDepth - 1 ? '' : 'ezurlalias_ml' . $level . '_';
553
            $isPathHistory = $isPathHistory ?: ($data[$prefix . 'link'] != $data[$prefix . 'id']);
554
            $hierarchyData[$level] = array(
555
                'id' => $data[$prefix . 'id'],
556
                'parent' => $data[$prefix . 'parent'],
557
                'action' => $data[$prefix . 'action'],
558
            );
559
        }
560
561
        $data['is_path_history'] = $isPathHistory;
562
        $data['raw_path_data'] = ($data['action_type'] == 'eznode' && !$data['is_alias'])
563
            ? $this->gateway->loadPathDataByHierarchy($hierarchyData)
564
            : $this->gateway->loadPathData($data['id']);
565
566
        return $this->mapper->extractUrlAliasFromData($data);
567
    }
568
569
    /**
570
     * Loads URL alias by given $id.
571
     *
572
     * @throws \eZ\Publish\API\Repository\Exceptions\NotFoundException
573
     *
574
     * @param string $id
575
     *
576
     * @return \eZ\Publish\SPI\Persistence\Content\UrlAlias
577
     */
578
    public function loadUrlAlias($id)
579
    {
580
        list($parentId, $textMD5) = explode('-', $id);
581
        $data = $this->gateway->loadRow($parentId, $textMD5);
582
583
        if (empty($data)) {
584
            throw new NotFoundException('URLAlias', $id);
585
        }
586
587
        $data['raw_path_data'] = $this->gateway->loadPathData($data['id']);
588
589
        return $this->mapper->extractUrlAliasFromData($data);
590
    }
591
592
    /**
593
     * Notifies the underlying engine that a location has moved.
594
     *
595
     * This method triggers the change of the autogenerated aliases.
596
     *
597
     * @param mixed $locationId
598
     * @param mixed $oldParentId
599
     * @param mixed $newParentId
600
     */
601
    public function locationMoved($locationId, $oldParentId, $newParentId)
602
    {
603
        // @todo optimize: $newLocationAliasId is already available in self::publishUrlAliasForLocation() as $newId
604
        $newParentLocationAliasId = $this->getRealAliasId($newParentId);
605
        $newLocationAlias = $this->gateway->loadAutogeneratedEntry(
606
            'eznode:' . $locationId,
607
            $newParentLocationAliasId
608
        );
609
610
        $oldParentLocationAliasId = $this->getRealAliasId($oldParentId);
611
        $oldLocationAlias = $this->gateway->loadAutogeneratedEntry(
612
            'eznode:' . $locationId,
613
            $oldParentLocationAliasId
614
        );
615
616
        // Historize alias for old location
617
        $this->gateway->historizeId($oldLocationAlias['id'], $newLocationAlias['id']);
618
        // Reparent subtree of old location to new location
619
        $this->gateway->reparent($oldLocationAlias['id'], $newLocationAlias['id']);
620
    }
621
622
    /**
623
     * Notifies the underlying engine that a location was copied.
624
     *
625
     * This method triggers the creation of the autogenerated aliases for the copied locations
626
     *
627
     * @param mixed $locationId
628
     * @param mixed $newLocationId
629
     * @param mixed $newParentId
630
     */
631
    public function locationCopied($locationId, $newLocationId, $newParentId)
632
    {
633
        $newParentAliasId = $this->getRealAliasId($newLocationId);
634
        $oldParentAliasId = $this->getRealAliasId($locationId);
635
636
        $actionMap = $this->getCopiedLocationsMap($locationId, $newLocationId);
637
638
        $this->copySubtree(
639
            $actionMap,
640
            $oldParentAliasId,
641
            $newParentAliasId
642
        );
643
    }
644
645
    /**
646
     * Notify the underlying engine that a Location has been swapped.
647
     *
648
     * This method triggers the change of the autogenerated aliases.
649
     *
650
     * @param int $location1Id
651
     * @param int $location1ParentId
652
     * @param int $location2Id
653
     * @param int $location2ParentId
654
     */
655
    public function locationSwapped($location1Id, $location1ParentId, $location2Id, $location2ParentId)
656
    {
657
        $location1 = new SwappedLocationProperties($location1Id, $location1ParentId);
658
        $location2 = new SwappedLocationProperties($location2Id, $location2ParentId);
659
660
        $location1->entries = $this->gateway->loadLocationEntries($location1Id);
661
        $location2->entries = $this->gateway->loadLocationEntries($location2Id);
662
663
        $location1->mainLanguageId = $this->gateway->getLocationContentMainLanguageId($location1Id);
664
        $location2->mainLanguageId = $this->gateway->getLocationContentMainLanguageId($location2Id);
665
666
        // Load autogenerated entries to find alias ID
667
        $location1->autogeneratedId = $this->gateway->loadAutogeneratedEntry("eznode:{$location1Id}")['id'];
668
        $location2->autogeneratedId = $this->gateway->loadAutogeneratedEntry("eznode:{$location2Id}")['id'];
669
670
        $contentInfo1 = $this->contentGateway->loadContentInfoByLocationId($location1Id);
671
        $contentInfo2 = $this->contentGateway->loadContentInfoByLocationId($location2Id);
672
673
        $names1 = $this->getNamesForAllLanguages($contentInfo1);
674
        $names2 = $this->getNamesForAllLanguages($contentInfo2);
675
676
        $location1->isAlwaysAvailable = $this->maskGenerator->isAlwaysAvailable($contentInfo1['language_mask']);
677
        $location2->isAlwaysAvailable = $this->maskGenerator->isAlwaysAvailable($contentInfo2['language_mask']);
678
679
        $languages = $this->languageHandler->loadAll();
680
681
        // Historize everything first to avoid name conflicts in case swapped Locations are siblings
682
        $this->historizeBeforeSwap($location1->entries, $location2->entries);
683
684
        foreach ($languages as $languageCode => $language) {
685
            $location1->name = isset($names1[$languageCode]) ? $names1[$languageCode] : null;
686
            $location2->name = isset($names2[$languageCode]) ? $names2[$languageCode] : null;
687
            $urlAliasesForSwappedLocations = $this->getUrlAliasesForSwappedLocations(
688
                $language,
689
                $location1,
690
                $location2
691
            );
692
            foreach ($urlAliasesForSwappedLocations as $urlAliasForLocation) {
693
                $this->internalPublishUrlAliasForLocation(
694
                    $urlAliasForLocation->id,
695
                    $urlAliasForLocation->parentId,
696
                    $urlAliasForLocation->name,
697
                    $language->id,
698
                    $urlAliasForLocation->isAlwaysAvailable,
699
                    $urlAliasForLocation->isPathIdentificationStringModified,
700
                    $urlAliasForLocation->newId
701
                );
702
            }
703
        }
704
    }
705
706
    /**
707
     * @param array $contentInfo
708
     *
709
     * @return array
710
     */
711
    private function getNamesForAllLanguages(array $contentInfo)
712
    {
713
        $nameDataArray = $this->contentGateway->loadVersionedNameData([
714
            [
715
                'id' => $contentInfo['id'],
716
                'version' => $contentInfo['current_version'],
717
            ],
718
        ]);
719
720
        $namesForAllLanguages = [];
721
        foreach ($nameDataArray as $nameData) {
722
            $namesForAllLanguages[$nameData['ezcontentobject_name_content_translation']]
723
                = $nameData['ezcontentobject_name_name'];
724
        }
725
726
        return $namesForAllLanguages;
727
    }
728
729
    /**
730
     * Historizes given existing active entries for two swapped Locations.
731
     *
732
     * This should be done before republishing URL aliases, in order to avoid unnecessary
733
     * conflicts when swapped Locations are siblings.
734
     *
735
     * We need to historize everything separately per language (mask), in case the entries
736
     * remain history future publishing reusages need to be able to take them over cleanly.
737
     *
738
     * @see \eZ\Publish\Core\Persistence\Legacy\Content\UrlAlias\Handler::locationSwapped()
739
     *
740
     * @param array $location1Entries
741
     * @param array $location2Entries
742
     */
743
    private function historizeBeforeSwap($location1Entries, $location2Entries)
744
    {
745
        foreach ($location1Entries as $row) {
746
            $this->gateway->historizeBeforeSwap($row['action'], $row['lang_mask']);
747
        }
748
749
        foreach ($location2Entries as $row) {
750
            $this->gateway->historizeBeforeSwap($row['action'], $row['lang_mask']);
751
        }
752
    }
753
754
    /**
755
     * Decides if UrlAlias for $location2 should be published first.
756
     *
757
     * The order in which Locations are published only matters if swapped Locations are siblings and they have the same
758
     * name in a given language. In this case, the UrlAlias for Location which previously had lower number at the end of
759
     * its UrlAlias text (or no number at all) should be published first. This ensures that the number still stays lower
760
     * for this Location after the swap. If it wouldn't stay lower, then swapping Locations in conjunction with swapping
761
     * UrlAliases would effectively cancel each other.
762
     *
763
     * @param array $location1Entries
764
     * @param int $location1ParentId
765
     * @param string $name1
766
     * @param array $location2Entries
767
     * @param int $location2ParentId
768
     * @param string $name2
769
     * @param int $languageId
770
     *
771
     * @return bool
772
     */
773
    private function shouldUrlAliasForSecondLocationBePublishedFirst(
774
        array $location1Entries,
775
        $location1ParentId,
776
        $name1,
777
        array $location2Entries,
778
        $location2ParentId,
779
        $name2,
780
        $languageId
781
    ) {
782
        if ($location1ParentId === $location2ParentId && $name1 === $name2) {
783
            $locationEntry1 = $this->getLocationEntryInLanguage($location1Entries, $languageId);
784
            $locationEntry2 = $this->getLocationEntryInLanguage($location2Entries, $languageId);
785
786
            if ($locationEntry1 === null || $locationEntry2 === null) {
787
                return false;
788
            }
789
790
            if ($locationEntry2['text'] < $locationEntry1['text']) {
791
                return true;
792
            }
793
        }
794
795
        return false;
796
    }
797
798
    /**
799
     * Get in a proper order - to be published - a list of URL aliases for swapped Locations.
800
     *
801
     * @see shouldUrlAliasForSecondLocationBePublishedFirst
802
     *
803
     * @param \eZ\Publish\SPI\Persistence\Content\Language $language
804
     * @param \eZ\Publish\Core\Persistence\Legacy\Content\UrlAlias\DTO\SwappedLocationProperties $location1
805
     * @param \eZ\Publish\Core\Persistence\Legacy\Content\UrlAlias\DTO\SwappedLocationProperties $location2
806
     *
807
     * @return \eZ\Publish\Core\Persistence\Legacy\Content\UrlAlias\DTO\UrlAliasForSwappedLocation[]
808
     */
809
    private function getUrlAliasesForSwappedLocations(
810
        Language $language,
811
        SwappedLocationProperties $location1,
812
        SwappedLocationProperties $location2
813
    ) {
814
        $isMainLanguage1 = $language->id == $location1->mainLanguageId;
815
        $isMainLanguage2 = $language->id == $location2->mainLanguageId;
816
        $urlAliases = [];
817
        if (isset($location1->name)) {
818
            $urlAliases[] = new UrlAliasForSwappedLocation(
819
                $location1->id,
820
                $location1->parentId,
821
                $location1->name,
822
                $isMainLanguage2 && $location1->isAlwaysAvailable,
823
                $isMainLanguage2,
824
                $location1->autogeneratedId
825
            );
826
        }
827
828
        if (isset($location2->name)) {
829
            $urlAliases[] = new UrlAliasForSwappedLocation(
830
                $location2->id,
831
                $location2->parentId,
832
                $location2->name,
833
                $isMainLanguage1 && $location2->isAlwaysAvailable,
834
                $isMainLanguage1,
835
                $location2->autogeneratedId
836
            );
837
838
            if (isset($location1->name) && $this->shouldUrlAliasForSecondLocationBePublishedFirst(
839
                    $location1->entries,
840
                    $location1->parentId,
841
                    $location1->name,
842
                    $location2->entries,
843
                    $location2->parentId,
844
                    $location2->name,
845
                    $language->id
846
                )) {
847
                $urlAliases = array_reverse($urlAliases);
848
            }
849
        }
850
851
        return $urlAliases;
852
    }
853
854
    /**
855
     * @param array $locationEntries
856
     * @param int $languageId
857
     *
858
     * @return array|null
859
     */
860
    private function getLocationEntryInLanguage(array $locationEntries, $languageId)
861
    {
862
        $entries = array_filter(
863
            $locationEntries,
864
            function (array $row) use ($languageId) {
865
                return (bool) ($row['lang_mask'] & $languageId);
866
            }
867
        );
868
869
        return !empty($entries) ? array_shift($entries) : null;
870
    }
871
872
    /**
873
     * Returns possibly corrected alias id for given $locationId !! For use as parent id in logic.
874
     *
875
     * First level entries must have parent id set to 0 instead of their parent location alias id.
876
     * There are two cases when alias id needs to be corrected:
877
     * 1) location is special location without URL alias (location with id=1 in standard installation)
878
     * 2) location is site root location, having special root entry in the ezurlalias_ml table (location with id=2
879
     *    in standard installation)
880
     *
881
     * @param mixed $locationId
882
     *
883
     * @return mixed
884
     */
885
    protected function getRealAliasId($locationId)
886
    {
887
        // Absolute root location does have a url alias entry so we can skip lookup
888
        if ($locationId == self::ROOT_LOCATION_ID) {
889
            return 0;
890
        }
891
892
        $data = $this->gateway->loadAutogeneratedEntry('eznode:' . $locationId);
893
894
        // Root entries (URL wise) can return 0 as the returned value is used as parent (parent is 0 for root entries)
895
        if (empty($data) || ($data['id'] != 0 && $data['parent'] == 0 && strlen($data['text']) == 0)) {
896
            $id = 0;
897
        } else {
898
            $id = $data['id'];
899
        }
900
901
        return $id;
902
    }
903
904
    /**
905
     * Recursively copies aliases from old parent under new parent.
906
     *
907
     * @param array $actionMap
908
     * @param mixed $oldParentAliasId
909
     * @param mixed $newParentAliasId
910
     */
911
    protected function copySubtree($actionMap, $oldParentAliasId, $newParentAliasId)
912
    {
913
        $rows = $this->gateway->loadAutogeneratedEntries($oldParentAliasId);
914
        $newIdsMap = array();
915
        foreach ($rows as $row) {
916
            $oldParentAliasId = $row['id'];
917
918
            // Ensure that same action entries remain grouped by the same id
919
            if (!isset($newIdsMap[$oldParentAliasId])) {
920
                $newIdsMap[$oldParentAliasId] = $this->gateway->getNextId();
921
            }
922
923
            $row['action'] = $actionMap[$row['action']];
924
            $row['parent'] = $newParentAliasId;
925
            $row['id'] = $row['link'] = $newIdsMap[$oldParentAliasId];
926
            $this->gateway->insertRow($row);
927
928
            $this->copySubtree(
929
                $actionMap,
930
                $oldParentAliasId,
931
                $row['id']
932
            );
933
        }
934
    }
935
936
    /**
937
     * @param mixed $oldParentId
938
     * @param mixed $newParentId
939
     *
940
     * @return array
941
     */
942
    protected function getCopiedLocationsMap($oldParentId, $newParentId)
943
    {
944
        $originalLocations = $this->locationGateway->getSubtreeContent($oldParentId);
945
        $copiedLocations = $this->locationGateway->getSubtreeContent($newParentId);
946
947
        $map = array();
948
        foreach ($originalLocations as $index => $originalLocation) {
949
            $map['eznode:' . $originalLocation['node_id']] = 'eznode:' . $copiedLocations[$index]['node_id'];
950
        }
951
952
        return $map;
953
    }
954
955
    /**
956
     * Notifies the underlying engine that a location was deleted or moved to trash.
957
     *
958
     * @param mixed $locationId
959
     */
960
    public function locationDeleted($locationId)
961
    {
962
        $action = 'eznode:' . $locationId;
963
        $entry = $this->gateway->loadAutogeneratedEntry($action);
964
965
        $this->removeSubtree($entry['id'], $action, $entry['is_original']);
966
    }
967
968
    /**
969
     * Notifies the underlying engine that Locations Content Translation was removed.
970
     *
971
     * @param int[] $locationIds all Locations of the Content that got Translation removed
972
     * @param string $languageCode language code of the removed Translation
973
     */
974
    public function translationRemoved(array $locationIds, $languageCode)
975
    {
976
        $languageId = $this->languageHandler->loadByLanguageCode($languageCode)->id;
977
978
        $actions = [];
979
        foreach ($locationIds as $locationId) {
980
            $actions[] = 'eznode:' . $locationId;
981
        }
982
        $this->gateway->bulkRemoveTranslation($languageId, $actions);
983
    }
984
985
    /**
986
     * Recursively removes aliases by given $id and $action.
987
     *
988
     * $original parameter is used to limit removal of moved Location aliases to history entries only.
989
     *
990
     * @param mixed $id
991
     * @param string $action
992
     * @param mixed $original
993
     */
994
    protected function removeSubtree($id, $action, $original)
995
    {
996
        // Remove first to avoid unnecessary recursion.
997
        if ($original) {
998
            // If entry is original remove all for action (history and custom entries included).
999
            $this->gateway->remove($action);
1000
        } else {
1001
            // Else entry is history, so remove only for action with the id.
1002
            // This means $id grouped history entries are removed, other history, active autogenerated
1003
            // and custom are left alone.
1004
            $this->gateway->remove($action, $id);
1005
        }
1006
1007
        // Load all autogenerated for parent $id, including history.
1008
        $entries = $this->gateway->loadAutogeneratedEntries($id, true);
1009
1010
        foreach ($entries as $entry) {
1011
            $this->removeSubtree($entry['id'], $entry['action'], $entry['is_original']);
1012
        }
1013
    }
1014
1015
    /**
1016
     * @param string $text
1017
     *
1018
     * @return string
1019
     */
1020
    protected function getHash($text)
1021
    {
1022
        return md5(mb_strtolower($text, 'UTF-8'));
1023
    }
1024
1025
    /**
1026
     * {@inheritdoc}
1027
     */
1028
    public function archiveUrlAliasesForDeletedTranslations($locationId, $parentLocationId, array $languageCodes)
1029
    {
1030
        $parentId = $this->getRealAliasId($parentLocationId);
1031
1032
        // filter removed Translations
1033
        $urlAliases = $this->listURLAliasesForLocation($locationId);
1034
        $removedLanguages = [];
1035
        foreach ($urlAliases as $urlAlias) {
1036
            $removedLanguages = array_merge(
1037
                $removedLanguages,
1038
                array_filter(
1039
                    $urlAlias->languageCodes,
1040
                    function ($languageCode) use ($languageCodes) {
1041
                        return !in_array($languageCode, $languageCodes);
1042
                    }
1043
                )
1044
            );
1045
        }
1046
1047
        if (empty($removedLanguages)) {
1048
            return;
1049
        }
1050
1051
        // map languageCodes to their IDs
1052
        $languageIds = array_map(
1053
            function ($languageCode) {
1054
                return $this->languageHandler->loadByLanguageCode($languageCode)->id;
1055
            },
1056
            $removedLanguages
1057
        );
1058
1059
        $this->gateway->archiveUrlAliasesForDeletedTranslations($locationId, $parentId, $languageIds);
1060
    }
1061
}
1062