Completed
Push — master ( 77f170...166d1e )
by Arne
03:02
created

Vault::buildMergedIndex()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 4
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
dl 0
loc 4
rs 10
c 0
b 0
f 0
cc 1
eloc 2
nc 1
nop 0
1
<?php
2
3
namespace Archivr;
4
5
use Archivr\ConnectionAdapter\ConnectionAdapterInterface;
6
use Archivr\Exception\Exception;
7
use Archivr\IndexMerger\IndexMergerInterface;
8
use Archivr\IndexMerger\StandardIndexMerger;
9
use Archivr\LockAdapter\ConnectionBasedLockAdapter;
10
use Archivr\LockAdapter\LockAdapterInterface;
11
use Archivr\OperationCollectionBuilder\OperationCollectionBuilderInterface;
12
use Archivr\OperationCollectionBuilder\StandardOperationCollectionBuilder;
13
use Archivr\SynchronizationProgressListener\DummySynchronizationProgressListener;
14
use Archivr\SynchronizationProgressListener\SynchronizationProgressListenerInterface;
15
use Ramsey\Uuid\Uuid;
16
use Symfony\Component\Finder\Finder;
17
use Symfony\Component\Finder\SplFileInfo;
18
use Archivr\Operation\OperationInterface;
19
20
class Vault
21
{
22
    use TildeExpansionTrait;
23
24
    const METADATA_DIRECTORY_NAME = '.archivr';
25
    const SYNCHRONIZATION_LIST_FILE_NAME = 'index';
26
    const LOCK_SYNC = 'sync';
27
28
    // Operations modes for building operation collection
29
    const MODE_PREFER_LOCAL = 'local';
30
    const MODE_PREFER_REMOTE = 'remote';
31
32
    /**
33
     * @var string
34
     */
35
    protected $title;
36
37
    /**
38
     * @var ConnectionAdapterInterface
39
     */
40
    protected $vaultConnection;
41
42
    /**
43
     * @var string
44
     */
45
    protected $localPath;
46
47
    /**
48
     * @var LockAdapterInterface
49
     */
50
    protected $lockAdapter;
51
52
    /**
53
     * @var IndexMergerInterface
54
     */
55
    protected $indexMerger;
56
57
    /**
58
     * @var OperationCollectionBuilderInterface
59
     */
60
    protected $operationCollectionBuilder;
61
62
    /**
63
     * @var string[]
64
     */
65
    protected $exclusions = [];
66
67
    /**
68
     * @var string
69
     */
70
    protected $identity;
71
72
73
    public function __construct(string $title, string $localPath, ConnectionAdapterInterface $vaultConnection)
74
    {
75
        $this->title = $title;
76
        $this->vaultConnection = $vaultConnection;
77
        $this->localPath = rtrim($this->expandTildePath($localPath), DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR;
78
    }
79
80
    public function getTitle(): string
81
    {
82
        return $this->title;
83
    }
84
85
    public function getLocalPath(): string
86
    {
87
        return $this->localPath;
88
    }
89
90
    public function setIndexMerger(IndexMergerInterface $indexMerger = null)
91
    {
92
        $this->indexMerger = $indexMerger;
93
94
        return $this;
95
    }
96
97
    public function getIndexMerger(): IndexMergerInterface
98
    {
99
        if ($this->indexMerger === null)
100
        {
101
            $this->indexMerger = new StandardIndexMerger();
102
        }
103
104
        return $this->indexMerger;
105
    }
106
107
    public function getOperationCollectionBuilder(): OperationCollectionBuilderInterface
108
    {
109
        if ($this->operationCollectionBuilder === null)
110
        {
111
            $this->operationCollectionBuilder = new StandardOperationCollectionBuilder();
112
        }
113
114
        return $this->operationCollectionBuilder;
115
    }
116
117
    public function setOperationCollectionBuilder(OperationCollectionBuilderInterface $operationCollectionBuilder = null): Vault
118
    {
119
        $this->operationCollectionBuilder = $operationCollectionBuilder;
120
121
        return $this;
122
    }
123
124
    public function setLockAdapter(LockAdapterInterface $lockAdapter = null): Vault
125
    {
126
        $this->lockAdapter = $lockAdapter;
127
128
        return $this;
129
    }
130
131
    public function getLockAdapter(): LockAdapterInterface
132
    {
133
        if ($this->lockAdapter === null)
134
        {
135
            $this->lockAdapter = new ConnectionBasedLockAdapter($this->vaultConnection);
136
        }
137
138
        return $this->lockAdapter;
139
    }
140
141
    public function getVaultConnection(): ConnectionAdapterInterface
142
    {
143
        return $this->vaultConnection;
144
    }
145
146
    public function getExclusions(): array
147
    {
148
        return $this->exclusions;
149
    }
150
151
    public function addExclusion(string $path): Vault
152
    {
153
        $this->exclusions[] = $path;
154
155
        return $this;
156
    }
157
158
    public function setExclusions(array $paths): Vault
159
    {
160
        $this->exclusions = array_values($paths);
0 ignored issues
show
Documentation Bug introduced by
It seems like array_values($paths) of type array<integer,?> is incompatible with the declared type array<integer,string> of property $exclusions.

Our type inference engine has found an assignment to a property that is incompatible with the declared type of that property.

Either this assignment is in error or the assigned type should be added to the documentation/type hint for that property..

Loading history...
161
162
        return $this;
163
    }
164
165
    public function getIdentity(): string
166
    {
167
        return $this->identity;
168
    }
169
170
    public function setIdentity(string $identity = null): Vault
171
    {
172
        $this->identity = $identity;
173
174
        return $this;
175
    }
176
177
    /**
178
     * Builds and returns an index representing the current local state.
179
     *
180
     * @return Index
181
     */
182
    public function buildLocalIndex(): Index
183
    {
184
        $finder = new Finder();
185
        $finder->in($this->localPath);
186
        $finder->ignoreDotFiles(false);
187
        $finder->ignoreVCS(true);
188
        $finder->exclude(static::METADATA_DIRECTORY_NAME);
189
        $finder->notPath('archivr.json');
190
191
        foreach ($this->exclusions as $path)
192
        {
193
            $finder->notPath($path);
194
        }
195
196
        $index = new Index();
197
198
        foreach ($finder->directories() as $fileInfo)
199
        {
200
            /** @var SplFileInfo $fileInfo */
201
202
            $index->addObject(IndexObject::fromPath($this->localPath, $fileInfo->getRelativePathname()));
203
        }
204
205
        foreach ($finder->files() as $fileInfo)
206
        {
207
            /** @var SplFileInfo $fileInfo */
208
209
            $index->addObject(IndexObject::fromPath($this->localPath, $fileInfo->getRelativePathname()));
210
        }
211
212
        return $index;
213
    }
214
215
    /**
216
     * Reads and returns the index representing the local state on the last synchronization.
217
     *
218
     * @return Index
219
     * @throws Exception
220
     */
221
    public function loadLastLocalIndex()
222
    {
223
        $index = null;
224
        $path = $this->getLastLocalIndexFilePath();
225
226
        if (is_file($path))
227
        {
228
            $stream = fopen($path, 'rb');
229
230
            $index = $this->readIndexFromStream($stream);
231
232
            fclose($stream);
233
        }
234
235
        return $index;
236
    }
237
238
    /**
239
     * Reads and returns the current remote index.
240
     *
241
     * @param int $revision Revision to load. Defaults to the last revision.
242
     *
243
     * @return Index
244
     */
245
    public function loadRemoteIndex(int $revision = null)
246
    {
247
        $list = null;
248
249 View Code Duplication
        if ($revision === null)
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...
250
        {
251
            $list = $this->loadSynchronizationList();
252
253
            if (!$list->getLastSynchronization())
254
            {
255
                return null;
256
            }
257
258
            $revision = $list->getLastSynchronization()->getRevision();
259
        }
260
261
        return $this->doLoadRemoteIndex($revision, $list);
262
    }
263
264
    /**
265
     * Computes and returns the index representing the vault state after the local index has been merged with the remote index.
266
     *
267
     * @return Index
268
     */
269
    public function buildMergedIndex(): Index
270
    {
271
        return $this->doBuildMergedIndex();
272
    }
273
274
    /**
275
     * Returns ordered collection of operations required to synchronize the vault with the local path.
276
     * In addition to the object specific operations contained in the returned OperationCollection additional operations
277
     * might be necessary like index updates that do not belong to specific index objects.
278
     *
279
     * @return OperationCollection
280
     */
281
    public function getOperationCollection(): OperationCollection
282
    {
283
        $localIndex = $this->buildLocalIndex();
284
        $lastLocalIndex = $this->loadLastLocalIndex();
285
        $remoteIndex = $this->loadRemoteIndex();
286
287
        $mergedIndex = $this->doBuildMergedIndex($localIndex, $lastLocalIndex, $remoteIndex);
288
289
        return $this->getOperationCollectionBuilder()->buildOperationCollection($mergedIndex, $localIndex, $remoteIndex);
290
    }
291
292
    /**
293
     * Synchronizes the local with the remote state by executing all operations returned by getOperationCollection() (broadly speaking).
294
     *
295
     * @param int $newRevision
296
     * @param bool $preferLocal
297
     * @param SynchronizationProgressListenerInterface $progressionListener
298
     *
299
     * @return OperationResultCollection
300
     * @throws Exception
301
     */
302
    public function synchronize(int $newRevision = null, bool $preferLocal = false, SynchronizationProgressListenerInterface $progressionListener = null): OperationResultCollection
303
    {
304
        if ($progressionListener === null)
305
        {
306
            $progressionListener = new DummySynchronizationProgressListener();
307
        }
308
309
        $localIndex = $this->buildLocalIndex();
310
        $lastLocalIndex = $this->loadLastLocalIndex();
311
312
313
        if (!$this->getLockAdapter()->acquireLock(static::LOCK_SYNC))
314
        {
315
            throw new Exception('Failed to acquire lock.');
316
        }
317
318
319
        $synchronizationList = $this->loadSynchronizationList();
320
        $lastSynchronization = $synchronizationList->getLastSynchronization();
321
322
        if ($lastSynchronization)
323
        {
324
            $newRevision = $newRevision ?: ($lastSynchronization->getRevision() + 1);
325
            $remoteIndex = $this->doLoadRemoteIndex($lastSynchronization->getRevision(), $synchronizationList);
326
        }
327
        else
328
        {
329
            $newRevision = $newRevision ?: 1;
330
            $remoteIndex = null;
331
        }
332
333
        $synchronization = new Synchronization($newRevision, $this->generateNewBlobId(), new \DateTime(), $this->identity);
334
        $synchronizationList->addSynchronization($synchronization);
335
336
        // don't merge indices but just use local
337
        if ($preferLocal)
338
        {
339
            $mergedIndex = $localIndex;
340
        }
341
342
        // compute merged index
343
        else
344
        {
345
            $mergedIndex = $this->doBuildMergedIndex($localIndex, $lastLocalIndex, $remoteIndex);
346
        }
347
348
        $operationCollection = $this->getOperationCollectionBuilder()->buildOperationCollection($mergedIndex, $localIndex, $remoteIndex);
349
350
        $operationResultCollection = new OperationResultCollection();
351
352
        // operation count +
353
        // merged index write +
354
        // copy merged index to vault +
355
        // save merged index as last local index +
356
        // upload synchronization list +
357
        // release lock
358
        $progressionListener->start(count($operationCollection) + 5);
359
360 View Code Duplication
        foreach ($operationCollection as $operation)
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...
361
        {
362
            /** @var OperationInterface $operation */
363
364
            $success = $operation->execute($this->getLocalPath(), $this->getVaultConnection());
365
366
            $operationResult = new OperationResult($operation, $success);
367
            $operationResultCollection->addOperationResult($operationResult);
368
369
            $progressionListener->advance();
370
        }
371
372
        // dump new index
373
        $mergedIndexFilePath = tempnam(sys_get_temp_dir(), 'index');
374
        $this->writeIndexToFile($mergedIndex, $mergedIndexFilePath);
375
376
        $progressionListener->advance();
377
378
        // upload new index
379
        $readStream = fopen($mergedIndexFilePath, 'rb');
380
        $compressionFilter = stream_filter_append($readStream, 'zlib.deflate');
381
        $this->vaultConnection->writeStream($synchronization->getBlobId(), $readStream);
382
        rewind($readStream);
383
        stream_filter_remove($compressionFilter);
384
385
        $progressionListener->advance();
386
387
        // save new index locally
388
        $writeStream = fopen($this->getLastLocalIndexFilePath(), 'wb');
389
        stream_copy_to_stream($readStream, $writeStream);
390
        fclose($writeStream);
391
        fclose($readStream);
392
393
        $progressionListener->advance();
394
395
        // upload new synchronization list
396
        $synchronizationListFilePath = $this->writeSynchronizationListToTemporaryFile($synchronizationList);
397
        $readStream = fopen($synchronizationListFilePath, 'rb');
398
        stream_filter_append($readStream, 'zlib.deflate');
399
        $this->vaultConnection->writeStream(static::SYNCHRONIZATION_LIST_FILE_NAME, $readStream);
400
        fclose($readStream);
401
402
        // release lock
403
        if (!$this->getLockAdapter()->releaseLock(static::LOCK_SYNC))
404
        {
405
            throw new Exception('Failed to release lock.');
406
        }
407
408
        $progressionListener->advance();
409
        $progressionListener->finish();
410
411
        return $operationResultCollection;
412
    }
413
414
    /**
415
     * Loads and returns the list of synchronizations from the vault.
416
     *
417
     * @return SynchronizationList
418
     */
419
    public function loadSynchronizationList(): SynchronizationList
420
    {
421
        $list = null;
0 ignored issues
show
Unused Code introduced by
$list is not used, you could remove the assignment.

This check looks for variable assignements that are either overwritten by other assignments or where the variable is not used subsequently.

$myVar = 'Value';
$higher = false;

if (rand(1, 6) > 3) {
    $higher = true;
} else {
    $higher = false;
}

Both the $myVar assignment in line 1 and the $higher assignment in line 2 are dead. The first because $myVar is never used and the second because $higher is always overwritten for every possible time line.

Loading history...
422
423
        if ($this->vaultConnection->exists(static::SYNCHRONIZATION_LIST_FILE_NAME))
424
        {
425
            $stream = $this->vaultConnection->getReadStream(static::SYNCHRONIZATION_LIST_FILE_NAME);
426
427
            stream_filter_append($stream, 'zlib.inflate');
428
429
            $list = $this->readSynchronizationListFromStream($stream);
430
431
            fclose($stream);
432
433
            return $list;
434
        }
435
436
        return new SynchronizationList();
437
    }
438
439
    /**
440
     * Restores the local state at the given revision from the vault.
441
     *
442
     * @param int $revision
443
     * @param SynchronizationProgressListenerInterface $progressionListener
444
     *
445
     * @return OperationResultCollection
446
     * @throws Exception
447
     */
448
    public function restore(int $revision = null, SynchronizationProgressListenerInterface $progressionListener = null): OperationResultCollection
449
    {
450
        return $this->doRestore($revision, $progressionListener);
451
    }
452
453
    /**
454
     * @param string $targetPath
455
     * @param int $revision
456
     * @param SynchronizationProgressListenerInterface|null $progressListener
457
     *
458
     * @return OperationResultCollection
459
     * @throws \Exception
460
     */
461
    public function dump(string $targetPath, int $revision = null, SynchronizationProgressListenerInterface $progressListener = null): OperationResultCollection
462
    {
463
        $originalLocalPath = $this->localPath;
464
        $this->localPath =  rtrim($this->expandTildePath($targetPath), DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR;
465
466
        try
467
        {
468
            return $this->doRestore($revision, $progressListener, true);
469
        }
470
        catch (\Exception $exception)
471
        {
472
            throw $exception;
473
        }
474
        finally
475
        {
476
            $this->localPath = $originalLocalPath;
477
        }
478
    }
479
480
    protected function doLoadRemoteIndex(int $revision, SynchronizationList $synchronizationList = null)
481
    {
482
        if ($synchronizationList === null)
483
        {
484
            $synchronizationList = $this->loadSynchronizationList();
485
        }
486
487
        $synchronization = $synchronizationList->getSynchronizationByRevision($revision);
488
489
        if (!$synchronization)
490
        {
491
            return null;
492
        }
493
494
        $index = null;
495
496
        if ($this->vaultConnection->exists($synchronization->getBlobId()))
497
        {
498
            $stream = $this->vaultConnection->getReadStream($synchronization->getBlobId());
499
500
            stream_filter_append($stream, 'zlib.inflate');
501
502
            $index = $this->readIndexFromStream($stream);
503
504
            fclose($stream);
505
        }
506
507
        return $index;
508
    }
509
510
    protected function doBuildMergedIndex(Index $localIndex = null, Index $lastLocalIndex = null, Index $remoteIndex = null)
511
    {
512
        $localIndex = $localIndex ?: $this->buildLocalIndex();
513
        $lastLocalIndex = $lastLocalIndex ?: $this->loadLastLocalIndex();
514
        $remoteIndex = $remoteIndex ?: $this->loadRemoteIndex();
515
516
        if ($remoteIndex === null)
517
        {
518
            return $localIndex;
519
        }
520
521
        return $this->getIndexMerger()->merge($remoteIndex, $localIndex, $lastLocalIndex);
522
    }
523
524
    protected function doRestore(int $revision = null, SynchronizationProgressListenerInterface $progressionListener = null, bool $skipLastLocalIndexUpdate = false): OperationResultCollection
525
    {
526
        if ($progressionListener === null)
527
        {
528
            $progressionListener = new DummySynchronizationProgressListener();
529
        }
530
531
        if (!$this->getLockAdapter()->acquireLock(static::LOCK_SYNC))
532
        {
533
            throw new Exception('Failed to acquire lock.');
534
        }
535
536 View Code Duplication
        if ($revision === null)
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...
537
        {
538
            $synchronizationList = $this->loadSynchronizationList();
539
540
            if (!$synchronizationList->getLastSynchronization())
541
            {
542
                throw new Exception('No revision to restore from.');
543
            }
544
545
            $revision = $synchronizationList->getLastSynchronization()->getRevision();
546
        }
547
548
        $remoteIndex = $this->loadRemoteIndex($revision);
549
550
        if ($remoteIndex === null)
551
        {
552
            throw new Exception("Unknown revision: {$revision}");
553
        }
554
555
        $localIndex = $this->buildLocalIndex();
556
557
        $operationCollection = $this->getOperationCollectionBuilder()->buildOperationCollection($remoteIndex, $localIndex, $remoteIndex);
558
559
        $operationResultCollection = new OperationResultCollection();
560
561
        // operation count +
562
        // save merged index as last local index +
563
        // release lock
564
        $progressionListener->start(count($operationCollection) + 2);
565
566 View Code Duplication
        foreach ($operationCollection as $operation)
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...
567
        {
568
            /** @var OperationInterface $operation */
569
570
            $success = $operation->execute($this->getLocalPath(), $this->getVaultConnection());
571
572
            $operationResult = new OperationResult($operation, $success);
573
            $operationResultCollection->addOperationResult($operationResult);
574
575
            $progressionListener->advance();
576
        }
577
578
        if (!$skipLastLocalIndexUpdate)
579
        {
580
            $this->writeIndexToFile($remoteIndex, $this->getLastLocalIndexFilePath());
581
        }
582
583
        $progressionListener->advance();
584
585
        if (!$this->getLockAdapter()->releaseLock(static::LOCK_SYNC))
586
        {
587
            throw new Exception('Failed to release lock.');
588
        }
589
590
        $progressionListener->advance();
591
        $progressionListener->finish();
592
593
        return $operationResultCollection;
594
    }
595
596
    protected function readIndexFromStream($stream): Index
597
    {
598
        if (!is_resource($stream))
599
        {
600
            throw new Exception();
601
        }
602
603
        $index = new Index();
604
605
        while (($row = fgetcsv($stream)) !== false)
606
        {
607
            $index->addObject(IndexObject::fromIndexRecord($row));
608
        }
609
610
        return $index;
611
    }
612
613
    protected function writeIndexToFile(Index $index, string $path)
614
    {
615
        $stream = fopen($path, 'wb');
616
617
        foreach ($index as $object)
618
        {
619
            /** @var IndexObject $object */
620
621
            if (fputcsv($stream, $object->getIndexRecord()) === false)
622
            {
623
                throw new Exception();
624
            }
625
        }
626
627
        fclose($stream);
628
    }
629
630
    protected function readSynchronizationListFromStream($stream): SynchronizationList
631
    {
632
        if (!is_resource($stream))
633
        {
634
            throw new Exception();
635
        }
636
637
        $list = new SynchronizationList();
638
639
        while (($row = fgetcsv($stream)) !== false)
640
        {
641
            $list->addSynchronization(Synchronization::fromRecord($row));
642
        }
643
644
        return $list;
645
    }
646
647
    protected function writeSynchronizationListToTemporaryFile(SynchronizationList $synchronizationList): string
648
    {
649
        $path = tempnam(sys_get_temp_dir(), 'synchronizationList');
650
        $stream = fopen($path, 'wb');
651
652
        foreach ($synchronizationList as $synchronization)
653
        {
654
            /** @var Synchronization $synchronization */
655
656
            if (fputcsv($stream, $synchronization->getRecord()) === false)
657
            {
658
                throw new Exception();
659
            }
660
        }
661
662
        fclose($stream);
663
664
        return $path;
665
    }
666
667
    protected function generateNewBlobId(): string
668
    {
669
        do
670
        {
671
            $blobId = Uuid::uuid4()->toString();
672
        }
673
        while ($this->vaultConnection->exists($blobId));
674
675
        return $blobId;
676
    }
677
678
    protected function initMetadataDirectory(): string
679
    {
680
        $path = $this->localPath . static::METADATA_DIRECTORY_NAME;
681
682
        if (!is_dir($path))
683
        {
684
            if (!mkdir($path))
685
            {
686
                throw new Exception();
687
            }
688
        }
689
690
        return $path . DIRECTORY_SEPARATOR;
691
    }
692
693
    protected function getLastLocalIndexFilePath(): string
694
    {
695
        return $this->initMetadataDirectory() . sprintf('lastLocalIndex-%s', $this->getTitle());
696
    }
697
}
698