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

Handler::__construct()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 19

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
nc 1
nop 8
dl 0
loc 19
rs 9.6333
c 0
b 0
f 0

How to fix   Many Parameters   

Many Parameters

Methods with many parameters are not only hard to understand, but their parameters also often become inconsistent when you need more, or different data.

There are several approaches to avoid long parameter lists:

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