Completed
Push — 6.7 ( bdbdd3...39ca72 )
by André
15:08
created

Handler::lookup()   D

Complexity

Conditions 9
Paths 44

Size

Total Lines 36
Code Lines 24

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 9
eloc 24
nc 44
nop 1
dl 0
loc 36
rs 4.909
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\InvalidArgumentException;
12
use eZ\Publish\SPI\Persistence\Content\UrlAlias\Handler as UrlAliasHandlerInterface;
13
use eZ\Publish\SPI\Persistence\Content\Language\Handler as LanguageHandler;
14
use eZ\Publish\Core\Persistence\Legacy\Content\Location\Gateway as LocationGateway;
15
use eZ\Publish\Core\Base\Exceptions\NotFoundException;
16
use eZ\Publish\Core\Base\Exceptions\ForbiddenException;
17
use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
18
19
/**
20
 * The UrlAlias Handler provides nice urls management.
21
 *
22
 * Its methods operate on a representation of the url alias data structure held
23
 * inside a storage engine.
24
 */
25
class Handler implements UrlAliasHandlerInterface
26
{
27
    const ROOT_LOCATION_ID = 1;
28
29
    /**
30
     * This is intentionally hardcoded for now as:
31
     * 1. We don't implement this configuration option.
32
     * 2. Such option should not be in this layer, should be handled higher up.
33
     *
34
     * @deprecated
35
     */
36
    const CONTENT_REPOSITORY_ROOT_LOCATION_ID = 2;
37
38
    /**
39
     * The maximum level of alias depth.
40
     */
41
    const MAX_URL_ALIAS_DEPTH_LEVEL = 60;
42
43
    /**
44
     * UrlAlias Gateway.
45
     *
46
     * @var \eZ\Publish\Core\Persistence\Legacy\Content\UrlAlias\Gateway
47
     */
48
    protected $gateway;
49
50
    /**
51
     * Gateway for handling location data.
52
     *
53
     * @var \eZ\Publish\Core\Persistence\Legacy\Content\Location\Gateway
54
     */
55
    protected $locationGateway;
56
57
    /**
58
     * UrlAlias Mapper.
59
     *
60
     * @var \eZ\Publish\Core\Persistence\Legacy\Content\UrlAlias\Mapper
61
     */
62
    protected $mapper;
63
64
    /**
65
     * Caching language handler.
66
     *
67
     * @var \eZ\Publish\Core\Persistence\Legacy\Content\Language\CachingHandler
68
     */
69
    protected $languageHandler;
70
71
    /**
72
     * URL slug converter.
73
     *
74
     * @var \eZ\Publish\Core\Persistence\Legacy\Content\UrlAlias\SlugConverter
75
     */
76
    protected $slugConverter;
77
78
    /**
79
     * Creates a new UrlAlias Handler.
80
     *
81
     * @param \eZ\Publish\Core\Persistence\Legacy\Content\UrlAlias\Gateway $gateway
82
     * @param \eZ\Publish\Core\Persistence\Legacy\Content\UrlAlias\Mapper $mapper
83
     * @param \eZ\Publish\Core\Persistence\Legacy\Content\Location\Gateway $locationGateway
84
     * @param \eZ\Publish\SPI\Persistence\Content\Language\Handler $languageHandler
85
     * @param \eZ\Publish\Core\Persistence\Legacy\Content\UrlAlias\SlugConverter $slugConverter
86
     */
87
    public function __construct(
88
        Gateway $gateway,
89
        Mapper $mapper,
90
        LocationGateway $locationGateway,
91
        LanguageHandler $languageHandler,
92
        SlugConverter $slugConverter
93
    ) {
94
        $this->gateway = $gateway;
95
        $this->mapper = $mapper;
96
        $this->locationGateway = $locationGateway;
97
        $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...
98
        $this->slugConverter = $slugConverter;
99
    }
100
101
    public function publishUrlAliasForLocation(
102
        $locationId,
103
        $parentLocationId,
104
        $name,
105
        $languageCode,
106
        $alwaysAvailable = false,
107
        $updatePathIdentificationString = false
108
    ) {
109
        $languageId = $this->languageHandler->loadByLanguageCode($languageCode)->id;
110
111
        $this->internalPublishUrlAliasForLocation(
112
            $locationId,
113
            $parentLocationId,
114
            $name,
115
            $languageId,
116
            $alwaysAvailable,
117
            $updatePathIdentificationString
118
        );
119
    }
120
121
    /**
122
     * Internal publish method, accepting language ID instead of language code and optionally
123
     * new alias ID (used when swapping Locations).
124
     *
125
     * @see \eZ\Publish\Core\Persistence\Legacy\Content\UrlAlias\Handler::locationSwapped()
126
     *
127
     * @param int $locationId
128
     * @param int $parentLocationId
129
     * @param string $name
130
     * @param int $languageId
131
     * @param bool $alwaysAvailable
132
     * @param bool $updatePathIdentificationString legacy storage specific for updating ezcontentobject_tree.path_identification_string
133
     * @param int $newId
134
     */
135
    private function internalPublishUrlAliasForLocation(
136
        $locationId,
137
        $parentLocationId,
138
        $name,
139
        $languageId,
140
        $alwaysAvailable = false,
141
        $updatePathIdentificationString = false,
142
        $newId = null
143
    ) {
144
        $parentId = $this->getRealAliasId($parentLocationId);
145
        $name = $this->slugConverter->convert($name, 'location_' . $locationId);
146
        $uniqueCounter = $this->slugConverter->getUniqueCounterValue($name, $parentId == 0);
147
        $languageMask = $languageId | (int)$alwaysAvailable;
148
        $action = 'eznode:' . $locationId;
149
        $cleanup = false;
150
151
        // Exiting the loop with break;
152
        while (true) {
153
            $newText = '';
154
            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...
155
                $newText = $name . ($uniqueCounter > 1 ? $uniqueCounter : '');
156
            }
157
            $newTextMD5 = $this->getHash($newText);
158
159
            // Try to load existing entry
160
            $row = $this->gateway->loadRow($parentId, $newTextMD5);
161
162
            // If nothing was returned insert new entry
163
            if (empty($row)) {
164
                // Check for existing active location entry on this level and reuse it's id
165
                $existingLocationEntry = $this->gateway->loadAutogeneratedEntry($action, $parentId);
166
                if (!empty($existingLocationEntry)) {
167
                    $cleanup = true;
168
                    $newId = $existingLocationEntry['id'];
169
                }
170
171
                try {
172
                    $newId = $this->gateway->insertRow(
173
                        array(
174
                            'id' => $newId,
175
                            'link' => $newId,
176
                            'parent' => $parentId,
177
                            'action' => $action,
178
                            'lang_mask' => $languageMask,
179
                            'text' => $newText,
180
                            'text_md5' => $newTextMD5,
181
                        )
182
                    );
183
                } catch (\RuntimeException $e) {
184
                    while ($e->getPrevious() !== null) {
185
                        $e = $e->getPrevious();
186
                        if ($e instanceof UniqueConstraintViolationException) {
187
                            // Concurrency! someone else inserted the same row that we where going to.
188
                            // let's do another loop pass
189
                            $uniqueCounter += 1;
190
                            continue 2;
191
                        }
192
                    }
193
194
                    throw $e;
195
                }
196
197
                break;
198
            }
199
200
            // Row exists, check if it is reusable. There are 3 cases when this is possible:
201
            // 1. NOP entry
202
            // 2. existing location or custom alias entry
203
            // 3. history entry
204
            if ($row['action'] == 'nop:' || $row['action'] == $action || $row['is_original'] == 0) {
205
                // Check for existing location entry on this level, if it exists and it's id differs from reusable
206
                // entry id then reusable entry should be updated with the existing location entry id.
207
                // Note: existing location entry may be downgraded and relinked later, depending on its language.
208
                $existingLocationEntry = $this->gateway->loadAutogeneratedEntry($action, $parentId);
209
210
                if (!empty($existingLocationEntry)) {
211
                    // Always cleanup when active autogenerated entry exists on the same level
212
                    $cleanup = true;
213
                    $newId = $existingLocationEntry['id'];
214
                    if ($existingLocationEntry['id'] == $row['id']) {
215
                        // If we are reusing existing location entry merge existing language mask
216
                        $languageMask |= ($row['lang_mask'] & ~1);
217
                    }
218
                } elseif ($newId === null) {
219
                    // Use reused row ID only if publishing normally, else use given $newId
220
                    $newId = $row['id'];
221
                }
222
223
                $this->gateway->updateRow(
224
                    $parentId,
225
                    $newTextMD5,
226
                    array(
227
                        'action' => $action,
228
                        // In case when NOP row was reused
229
                        'action_type' => 'eznode',
230
                        'lang_mask' => $languageMask,
231
                        // Updating text ensures that letter case changes are stored
232
                        'text' => $newText,
233
                        // Set "id" and "link" for case when reusable entry is history
234
                        'id' => $newId,
235
                        'link' => $newId,
236
                        // Entry should be active location entry (original and not alias).
237
                        // Note: this takes care of taking over custom alias entry for the location on the same level
238
                        // and with same name and action.
239
                        'alias_redirects' => 1,
240
                        'is_original' => 1,
241
                        'is_alias' => 0,
242
                    )
243
                );
244
245
                break;
246
            }
247
248
            // If existing row is not reusable, increment $uniqueCounter and try again
249
            $uniqueCounter += 1;
250
        }
251
252
        /* @var $newText */
253
        if ($updatePathIdentificationString) {
254
            $this->locationGateway->updatePathIdentificationString(
255
                $locationId,
256
                $parentLocationId,
257
                $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...
258
            );
259
        }
260
261
        /* @var $newId */
262
        /* @var $newTextMD5 */
263
        // Note: cleanup does not touch custom and global entries
264
        if ($cleanup) {
265
            $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...
266
        }
267
    }
268
269
    /**
270
     * Create a user chosen $alias pointing to $locationId in $languageCode.
271
     *
272
     * If $languageCode is null the $alias is created in the system's default
273
     * language. $alwaysAvailable makes the alias available in all languages.
274
     *
275
     * @param mixed $locationId
276
     * @param string $path
277
     * @param bool $forwarding
278
     * @param string $languageCode
279
     * @param bool $alwaysAvailable
280
     *
281
     * @return \eZ\Publish\SPI\Persistence\Content\UrlAlias
282
     */
283
    public function createCustomUrlAlias($locationId, $path, $forwarding = false, $languageCode = null, $alwaysAvailable = false)
284
    {
285
        return $this->createUrlAlias(
286
            'eznode:' . $locationId,
287
            $path,
288
            $forwarding,
289
            $languageCode,
290
            $alwaysAvailable
291
        );
292
    }
293
294
    /**
295
     * Create a user chosen $alias pointing to a resource in $languageCode.
296
     * This method does not handle location resources - if a user enters a location target
297
     * the createCustomUrlAlias method has to be used.
298
     *
299
     * If $languageCode is null the $alias is created in the system's default
300
     * language. $alwaysAvailable makes the alias available in all languages.
301
     *
302
     * @throws \eZ\Publish\API\Repository\Exceptions\ForbiddenException if the path already exists for the given language
303
     *
304
     * @param string $resource
305
     * @param string $path
306
     * @param bool $forwarding
307
     * @param string $languageCode
308
     * @param bool $alwaysAvailable
309
     *
310
     * @return \eZ\Publish\SPI\Persistence\Content\UrlAlias
311
     */
312
    public function createGlobalUrlAlias($resource, $path, $forwarding = false, $languageCode = null, $alwaysAvailable = false)
313
    {
314
        return $this->createUrlAlias(
315
            $resource,
316
            $path,
317
            $forwarding,
318
            $languageCode,
319
            $alwaysAvailable
320
        );
321
    }
322
323
    /**
324
     * Internal method for creating global or custom URL alias (these are handled in the same way).
325
     *
326
     * @throws \eZ\Publish\Core\Base\Exceptions\ForbiddenException if the path already exists for the given language
327
     *
328
     * @param string $action
329
     * @param string $path
330
     * @param bool $forward
331
     * @param string|null $languageCode
332
     * @param bool $alwaysAvailable
333
     *
334
     * @return \eZ\Publish\SPI\Persistence\Content\UrlAlias
335
     */
336
    protected function createUrlAlias($action, $path, $forward, $languageCode, $alwaysAvailable)
337
    {
338
        $pathElements = explode('/', $path);
339
        $topElement = array_pop($pathElements);
340
        $languageId = $this->languageHandler->loadByLanguageCode($languageCode)->id;
341
        $parentId = 0;
342
343
        // Handle all path elements except topmost one
344
        $isPathNew = false;
345
        foreach ($pathElements as $level => $pathElement) {
346
            $pathElement = $this->slugConverter->convert($pathElement, 'noname' . ($level + 1));
347
            $pathElementMD5 = $this->getHash($pathElement);
348
            if (!$isPathNew) {
349
                $row = $this->gateway->loadRow($parentId, $pathElementMD5);
350
                if (empty($row)) {
351
                    $isPathNew = true;
352
                } else {
353
                    $parentId = $row['link'];
354
                }
355
            }
356
357
            if ($isPathNew) {
358
                $parentId = $this->insertNopEntry($parentId, $pathElement, $pathElementMD5);
359
            }
360
        }
361
362
        // Handle topmost path element
363
        $topElement = $this->slugConverter->convert($topElement, 'noname' . (count($pathElements) + 1));
364
365
        // If last (next to topmost) entry parent is special root entry we handle topmost entry as first level entry
366
        // That is why we need to reset $parentId to 0
367
        if ($parentId != 0 && $this->gateway->isRootEntry($parentId)) {
368
            $parentId = 0;
369
        }
370
371
        $topElementMD5 = $this->getHash($topElement);
372
        // Set common values for two cases below
373
        $data = array(
374
            'action' => $action,
375
            'is_alias' => 1,
376
            'alias_redirects' => $forward ? 1 : 0,
377
            'parent' => $parentId,
378
            'text' => $topElement,
379
            'text_md5' => $topElementMD5,
380
            'is_original' => 1,
381
        );
382
        // Try to load topmost element
383
        if (!$isPathNew) {
384
            $row = $this->gateway->loadRow($parentId, $topElementMD5);
385
        }
386
387
        // If nothing was returned perform insert
388
        if ($isPathNew || empty($row)) {
389
            $data['lang_mask'] = $languageId | (int)$alwaysAvailable;
390
            $id = $this->gateway->insertRow($data);
391
        } elseif ($row['action'] == 'nop:' || $row['is_original'] == 0) {
392
            // Row exists, check if it is reusable. There are 2 cases when this is possible:
393
            // 1. NOP entry
394
            // 2. history entry
395
            $data['lang_mask'] = $languageId | (int)$alwaysAvailable;
396
            // If history is reused move link to id
397
            $data['link'] = $id = $row['id'];
398
            $this->gateway->updateRow(
399
                $parentId,
400
                $topElementMD5,
401
                $data
402
            );
403
        } else {
404
            throw new ForbiddenException("Path '%path%' already exists for the given language", ['%path%' => $path]);
405
        }
406
407
        $data['raw_path_data'] = $this->gateway->loadPathData($id);
408
409
        return $this->mapper->extractUrlAliasFromData($data);
410
    }
411
412
    /**
413
     * Convenience method for inserting nop type row.
414
     *
415
     * @param mixed $parentId
416
     * @param string $text
417
     * @param string $textMD5
418
     *
419
     * @return mixed
420
     */
421
    protected function insertNopEntry($parentId, $text, $textMD5)
422
    {
423
        return $this->gateway->insertRow(
424
            array(
425
                'lang_mask' => 1,
426
                'action' => 'nop:',
427
                'parent' => $parentId,
428
                'text' => $text,
429
                'text_md5' => $textMD5,
430
            )
431
        );
432
    }
433
434
    /**
435
     * List of user generated or autogenerated url entries, pointing to $locationId.
436
     *
437
     * @param mixed $locationId
438
     * @param bool $custom if true the user generated aliases are listed otherwise the autogenerated
439
     *
440
     * @return \eZ\Publish\SPI\Persistence\Content\UrlAlias[]
441
     */
442 View Code Duplication
    public function listURLAliasesForLocation($locationId, $custom = false)
443
    {
444
        $data = $this->gateway->loadLocationEntries($locationId, $custom);
445
        foreach ($data as &$entry) {
446
            $entry['raw_path_data'] = $this->gateway->loadPathData($entry['id']);
447
        }
448
449
        return $this->mapper->extractUrlAliasListFromData($data);
450
    }
451
452
    /**
453
     * List global aliases.
454
     *
455
     * @param string|null $languageCode
456
     * @param int $offset
457
     * @param int $limit
458
     *
459
     * @return \eZ\Publish\SPI\Persistence\Content\UrlAlias[]
460
     */
461 View Code Duplication
    public function listGlobalURLAliases($languageCode = null, $offset = 0, $limit = -1)
462
    {
463
        $data = $this->gateway->listGlobalEntries($languageCode, $offset, $limit);
464
        foreach ($data as &$entry) {
465
            $entry['raw_path_data'] = $this->gateway->loadPathData($entry['id']);
466
        }
467
468
        return $this->mapper->extractUrlAliasListFromData($data);
469
    }
470
471
    /**
472
     * Removes url aliases.
473
     *
474
     * Autogenerated aliases are not removed by this method.
475
     *
476
     * @param \eZ\Publish\SPI\Persistence\Content\UrlAlias[] $urlAliases
477
     *
478
     * @return bool
479
     */
480
    public function removeURLAliases(array $urlAliases)
481
    {
482
        foreach ($urlAliases as $urlAlias) {
483
            if ($urlAlias->isCustom) {
484
                list($parentId, $textMD5) = explode('-', $urlAlias->id);
485
                if (!$this->gateway->removeCustomAlias($parentId, $textMD5)) {
486
                    return false;
487
                }
488
            }
489
        }
490
491
        return true;
492
    }
493
494
    /**
495
     * Looks up a url alias for the given url.
496
     *
497
     * @throws \eZ\Publish\API\Repository\Exceptions\NotFoundException
498
     * @throws \RuntimeException
499
     * @throws \eZ\Publish\Core\Base\Exceptions\NotFoundException
500
     * @throws \eZ\Publish\Core\Base\Exceptions\InvalidArgumentException
501
     *
502
     * @param string $url
503
     *
504
     * @return \eZ\Publish\SPI\Persistence\Content\UrlAlias
505
     */
506
    public function lookup($url)
507
    {
508
        $urlHashes = array();
509
        foreach (explode('/', $url) as $level => $text) {
510
            $urlHashes[$level] = $this->getHash($text);
511
        }
512
513
        $pathDepth = count($urlHashes);
514
        if ($pathDepth > self::MAX_URL_ALIAS_DEPTH_LEVEL) {
515
            throw new InvalidArgumentException('$urlHashes', 'Exceeded maximum depth level of content url alias.');
516
        }
517
518
        $data = $this->gateway->loadUrlAliasData($urlHashes);
519
        if (empty($data)) {
520
            throw new NotFoundException('URLAlias', $url);
521
        }
522
523
        $hierarchyData = array();
524
        $isPathHistory = false;
525
        for ($level = 0; $level < $pathDepth; ++$level) {
526
            $prefix = $level === $pathDepth - 1 ? '' : 'ezurlalias_ml' . $level . '_';
527
            $isPathHistory = $isPathHistory ?: ($data[$prefix . 'link'] != $data[$prefix . 'id']);
528
            $hierarchyData[$level] = array(
529
                'id' => $data[$prefix . 'id'],
530
                'parent' => $data[$prefix . 'parent'],
531
                'action' => $data[$prefix . 'action'],
532
            );
533
        }
534
535
        $data['is_path_history'] = $isPathHistory;
536
        $data['raw_path_data'] = ($data['action_type'] == 'eznode' && !$data['is_alias'])
537
            ? $this->gateway->loadPathDataByHierarchy($hierarchyData)
538
            : $this->gateway->loadPathData($data['id']);
539
540
        return $this->mapper->extractUrlAliasFromData($data);
541
    }
542
543
    /**
544
     * Loads URL alias by given $id.
545
     *
546
     * @throws \eZ\Publish\API\Repository\Exceptions\NotFoundException
547
     *
548
     * @param string $id
549
     *
550
     * @return \eZ\Publish\SPI\Persistence\Content\UrlAlias
551
     */
552
    public function loadUrlAlias($id)
553
    {
554
        list($parentId, $textMD5) = explode('-', $id);
555
        $data = $this->gateway->loadRow($parentId, $textMD5);
556
557
        if (empty($data)) {
558
            throw new NotFoundException('URLAlias', $id);
559
        }
560
561
        $data['raw_path_data'] = $this->gateway->loadPathData($data['id']);
562
563
        return $this->mapper->extractUrlAliasFromData($data);
564
    }
565
566
    /**
567
     * Notifies the underlying engine that a location has moved.
568
     *
569
     * This method triggers the change of the autogenerated aliases.
570
     *
571
     * @param mixed $locationId
572
     * @param mixed $oldParentId
573
     * @param mixed $newParentId
574
     */
575
    public function locationMoved($locationId, $oldParentId, $newParentId)
576
    {
577
        // @todo optimize: $newLocationAliasId is already available in self::publishUrlAliasForLocation() as $newId
578
        $newParentLocationAliasId = $this->getRealAliasId($newParentId);
579
        $newLocationAlias = $this->gateway->loadAutogeneratedEntry(
580
            'eznode:' . $locationId,
581
            $newParentLocationAliasId
582
        );
583
584
        $oldParentLocationAliasId = $this->getRealAliasId($oldParentId);
585
        $oldLocationAlias = $this->gateway->loadAutogeneratedEntry(
586
            'eznode:' . $locationId,
587
            $oldParentLocationAliasId
588
        );
589
590
        // Historize alias for old location
591
        $this->gateway->historizeId($oldLocationAlias['id'], $newLocationAlias['id']);
592
        // Reparent subtree of old location to new location
593
        $this->gateway->reparent($oldLocationAlias['id'], $newLocationAlias['id']);
594
    }
595
596
    /**
597
     * Notifies the underlying engine that a location was copied.
598
     *
599
     * This method triggers the creation of the autogenerated aliases for the copied locations
600
     *
601
     * @param mixed $locationId
602
     * @param mixed $newLocationId
603
     * @param mixed $newParentId
604
     */
605
    public function locationCopied($locationId, $newLocationId, $newParentId)
606
    {
607
        $newParentAliasId = $this->getRealAliasId($newLocationId);
608
        $oldParentAliasId = $this->getRealAliasId($locationId);
609
610
        $actionMap = $this->getCopiedLocationsMap($locationId, $newLocationId);
611
612
        $this->copySubtree(
613
            $actionMap,
614
            $oldParentAliasId,
615
            $newParentAliasId
616
        );
617
    }
618
619
    public function locationSwapped($location1Id, $location1ParentId, $location2Id, $location2ParentId)
620
    {
621
        $location1Entries = $this->gateway->loadLocationEntries($location1Id);
622
        $location2Entries = $this->gateway->loadLocationEntries($location2Id);
623
624
        $location1MainLanguageId = $this->gateway->getLocationContentMainLanguageId($location1Id);
625
        $location2MainLanguageId = $this->gateway->getLocationContentMainLanguageId($location2Id);
626
627
        // Load autogenerated entries to find alias ID
628
        $autoLocation1 = $this->gateway->loadAutogeneratedEntry("eznode:{$location1Id}");
629
        $autoLocation2 = $this->gateway->loadAutogeneratedEntry("eznode:{$location2Id}");
630
631
        // Historize everything first to avoid name conflicts in case swapped Locations are siblings
632
        $this->historizeBeforeSwap($location1Entries, $location2Entries);
633
634 View Code Duplication
        foreach ($location2Entries as $row) {
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
635
            $alwaysAvailable = (bool)($row['lang_mask'] & 1);
636
            $languageIds = $this->extractLanguageIdsFromMask($row['lang_mask']);
637
638
            foreach ($languageIds as $languageId) {
639
                $isMainLanguage = $languageId == $location2MainLanguageId;
640
                $this->internalPublishUrlAliasForLocation(
641
                    $location1Id,
642
                    $location1ParentId,
643
                    $row['text'],
644
                    $languageId,
645
                    $isMainLanguage && $alwaysAvailable,
646
                    $isMainLanguage,
647
                    $autoLocation1['id']
648
                );
649
            }
650
        }
651
652 View Code Duplication
        foreach ($location1Entries as $row) {
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
653
            $alwaysAvailable = (bool)($row['lang_mask'] & 1);
654
            $languageIds = $this->extractLanguageIdsFromMask($row['lang_mask']);
655
656
            foreach ($languageIds as $languageId) {
657
                $isMainLanguage = $languageId == $location1MainLanguageId;
658
                $this->internalPublishUrlAliasForLocation(
659
                    $location2Id,
660
                    $location2ParentId,
661
                    $row['text'],
662
                    $languageId,
663
                    $isMainLanguage && $alwaysAvailable,
664
                    $isMainLanguage,
665
                    $autoLocation2['id']
666
                );
667
            }
668
        }
669
    }
670
671
    /**
672
     * Historizes given existing active entries for two swapped Locations.
673
     *
674
     * This should be done before republishing URL aliases, in order to avoid unnecessary
675
     * conflicts when swapped Locations are siblings.
676
     *
677
     * We need to historize everything separately per language (mask), in case the entries
678
     * remain history future publishing reusages need to be able to take them over cleanly.
679
     *
680
     * @see \eZ\Publish\Core\Persistence\Legacy\Content\UrlAlias\Handler::locationSwapped()
681
     *
682
     * @param array $location1Entries
683
     * @param array $location2Entries
684
     */
685
    private function historizeBeforeSwap($location1Entries, $location2Entries)
686
    {
687
        foreach ($location1Entries as $row) {
688
            $this->gateway->historizeBeforeSwap($row['action'], $row['lang_mask']);
689
        }
690
691
        foreach ($location2Entries as $row) {
692
            $this->gateway->historizeBeforeSwap($row['action'], $row['lang_mask']);
693
        }
694
    }
695
696
    /**
697
     * Extracts every language Ids contained in $languageMask.
698
     *
699
     * @param int $languageMask
700
     *
701
     * @return int[] An array of language IDs
702
     */
703
    private function extractLanguageIdsFromMask($languageMask)
704
    {
705
        $exp = 2;
706
        $languageIds = [];
707
708
        // Decomposition of $languageMask into its binary components.
709
        while ($exp <= $languageMask) {
710
            if ($languageMask & $exp) {
711
                $languageIds[] = $exp;
712
            }
713
714
            $exp *= 2;
715
        }
716
717
        return $languageIds;
718
    }
719
720
    /**
721
     * Returns possibly corrected alias id for given $locationId !! For use as parent id in logic.
722
     *
723
     * First level entries must have parent id set to 0 instead of their parent location alias id.
724
     * There are two cases when alias id needs to be corrected:
725
     * 1) location is special location without URL alias (location with id=1 in standard installation)
726
     * 2) location is site root location, having special root entry in the ezurlalias_ml table (location with id=2
727
     *    in standard installation)
728
     *
729
     * @param mixed $locationId
730
     *
731
     * @return mixed
732
     */
733
    protected function getRealAliasId($locationId)
734
    {
735
        // Absolute root location does have a url alias entry so we can skip lookup
736
        if ($locationId == self::ROOT_LOCATION_ID) {
737
            return 0;
738
        }
739
740
        $data = $this->gateway->loadAutogeneratedEntry('eznode:' . $locationId);
741
742
        // Root entries (URL wise) can return 0 as the returned value is used as parent (parent is 0 for root entries)
743
        if (empty($data) || $data['id'] != 0 && $data['parent'] == 0 && strlen($data['text']) == 0) {
744
            $id = 0;
745
        } else {
746
            $id = $data['id'];
747
        }
748
749
        return $id;
750
    }
751
752
    /**
753
     * Recursively copies aliases from old parent under new parent.
754
     *
755
     * @param array $actionMap
756
     * @param mixed $oldParentAliasId
757
     * @param mixed $newParentAliasId
758
     */
759
    protected function copySubtree($actionMap, $oldParentAliasId, $newParentAliasId)
760
    {
761
        $rows = $this->gateway->loadAutogeneratedEntries($oldParentAliasId);
762
        $newIdsMap = array();
763
        foreach ($rows as $row) {
764
            $oldParentAliasId = $row['id'];
765
766
            // Ensure that same action entries remain grouped by the same id
767
            if (!isset($newIdsMap[$oldParentAliasId])) {
768
                $newIdsMap[$oldParentAliasId] = $this->gateway->getNextId();
769
            }
770
771
            $row['action'] = $actionMap[$row['action']];
772
            $row['parent'] = $newParentAliasId;
773
            $row['id'] = $row['link'] = $newIdsMap[$oldParentAliasId];
774
            $this->gateway->insertRow($row);
775
776
            $this->copySubtree(
777
                $actionMap,
778
                $oldParentAliasId,
779
                $row['id']
780
            );
781
        }
782
    }
783
784
    /**
785
     * @param mixed $oldParentId
786
     * @param mixed $newParentId
787
     *
788
     * @return array
789
     */
790
    protected function getCopiedLocationsMap($oldParentId, $newParentId)
791
    {
792
        $originalLocations = $this->locationGateway->getSubtreeContent($oldParentId);
793
        $copiedLocations = $this->locationGateway->getSubtreeContent($newParentId);
794
795
        $map = array();
796
        foreach ($originalLocations as $index => $originalLocation) {
797
            $map['eznode:' . $originalLocation['node_id']] = 'eznode:' . $copiedLocations[$index]['node_id'];
798
        }
799
800
        return $map;
801
    }
802
803
    /**
804
     * Notifies the underlying engine that a location was deleted or moved to trash.
805
     *
806
     * @param mixed $locationId
807
     */
808
    public function locationDeleted($locationId)
809
    {
810
        $action = 'eznode:' . $locationId;
811
        $entry = $this->gateway->loadAutogeneratedEntry($action);
812
813
        $this->removeSubtree($entry['id'], $action, $entry['is_original']);
814
    }
815
816
    /**
817
     * Recursively removes aliases by given $id and $action.
818
     *
819
     * $original parameter is used to limit removal of moved Location aliases to history entries only.
820
     *
821
     * @param mixed $id
822
     * @param string $action
823
     * @param mixed $original
824
     */
825
    protected function removeSubtree($id, $action, $original)
826
    {
827
        // Remove first to avoid unnecessary recursion.
828
        if ($original) {
829
            // If entry is original remove all for action (history and custom entries included).
830
            $this->gateway->remove($action);
831
        } else {
832
            // Else entry is history, so remove only for action with the id.
833
            // This means $id grouped history entries are removed, other history, active autogenerated
834
            // and custom are left alone.
835
            $this->gateway->remove($action, $id);
836
        }
837
838
        // Load all autogenerated for parent $id, including history.
839
        $entries = $this->gateway->loadAutogeneratedEntries($id, true);
840
841
        foreach ($entries as $entry) {
842
            $this->removeSubtree($entry['id'], $entry['action'], $entry['is_original']);
843
        }
844
    }
845
846
    /**
847
     * @param string $text
848
     *
849
     * @return string
850
     */
851
    protected function getHash($text)
852
    {
853
        return md5(mb_strtolower($text, 'UTF-8'));
854
    }
855
}
856