DateRange   A
last analyzed

Complexity

Total Complexity 37

Size/Duplication

Total Lines 266
Duplicated Lines 0 %

Importance

Changes 0
Metric Value
wmc 37
eloc 82
dl 0
loc 266
rs 9.44
c 0
b 0
f 0

6 Methods

Rating   Name   Duplication   Size   Complexity  
B checkWellFormed() 0 26 7
C diff() 0 62 13
A isBefore() 0 5 3
B normalize() 0 38 10
A isAfter() 0 5 3
A union() 0 5 1
1
<?php
2
/**
3
 * BEdita, API-first content management framework
4
 * Copyright 2017 ChannelWeb Srl, Chialab Srl
5
 *
6
 * This file is part of BEdita: you can redistribute it and/or modify
7
 * it under the terms of the GNU Lesser General Public License as published
8
 * by the Free Software Foundation, either version 3 of the License, or
9
 * (at your option) any later version.
10
 *
11
 * See LICENSE.LGPL or <http://gnu.org/licenses/lgpl-3.0.html> for more details.
12
 */
13
14
namespace BEdita\Core\Model\Entity;
15
16
use Cake\ORM\Entity;
17
18
/**
19
 * DateRange Entity
20
 *
21
 * @property int $id
22
 * @property int $object_id
23
 * @property \DateTimeInterface $start_date
24
 * @property \DateTimeInterface|null $end_date
25
 * @property array $params
26
 *
27
 * @property \BEdita\Core\Model\Entity\ObjectEntity $object
28
 */
29
class DateRange extends Entity
30
{
31
    /**
32
     * @inheritDoc
33
     */
34
    protected $_accessible = [
35
        '*' => true,
36
        'id' => false,
37
    ];
38
39
    /**
40
     * @inheritDoc
41
     */
42
    protected $_hidden = [
43
        'id',
44
        'object_id',
45
    ];
46
47
    /**
48
     * Check if this Date Range is before the passed Date Range.
49
     *
50
     * A Date Range is "before" another Date Range if its `end_date` is lower than,
51
     * or equal, to the other Date Range's `start_date`. If `end_date` is `null`,
52
     * for this purpose it assumes the same value as `start_date`.
53
     *
54
     * **Warning**: this method **does not** take `params` into account.
55
     *
56
     * @param \BEdita\Core\Model\Entity\DateRange $dateRange Date Range being compared.
57
     * @return bool
58
     */
59
    public function isBefore(DateRange $dateRange)
60
    {
61
        static::checkWellFormed($this, $dateRange);
62
63
        return $this->start_date < $dateRange->start_date && ($this->end_date === null || $this->end_date <= $dateRange->start_date);
64
    }
65
66
    /**
67
     * Check if this Date Range is after the passed Date Range.
68
     *
69
     * A Date Range is "after" another Date Range if its `start_date` is greater than,
70
     * or equal, to the other Date Range's `end_date`. If `end_date` is `null`,
71
     * for this purpose it assumes the same value as `start_date`.
72
     *
73
     * **Warning**: this method **does not** take `params` into account.
74
     *
75
     * @param \BEdita\Core\Model\Entity\DateRange $dateRange Date Range being compared.
76
     * @return bool
77
     */
78
    public function isAfter(DateRange $dateRange)
79
    {
80
        static::checkWellFormed($this, $dateRange);
81
82
        return $this->start_date > $dateRange->start_date && ($dateRange->end_date === null || $this->start_date >= $dateRange->end_date);
83
    }
84
85
    /**
86
     * Normalize an array of Date Ranges by sorting and joining overlapping Date Ranges.
87
     *
88
     * Normalization sorts Date Ranges in a set by `start_date` in ascending order.
89
     * Also, if two or more Date Ranges do overlap, or are adjacent
90
     * (i.e. `$d1->end_date === $d2->start_date`), they are merged in one Date Range.
91
     * Duplicate Date Ranges are removed.
92
     *
93
     * **Warning**: this method **does not** take `params` into account.
94
     *
95
     * @param \BEdita\Core\Model\Entity\DateRange[] $dateRanges Set of Date Ranges.
96
     * @return \BEdita\Core\Model\Entity\DateRange[]
97
     */
98
    public static function normalize(array $dateRanges)
99
    {
100
        if (empty($dateRanges)) {
101
            return [];
102
        }
103
        static::checkWellFormed(...$dateRanges);
104
105
        // Sort items.
106
        usort($dateRanges, function (DateRange $dateRange1, DateRange $dateRange2) {
107
            if ($dateRange1->isBefore($dateRange2)) {
108
                return -1;
109
            }
110
            if ($dateRange1->isAfter($dateRange2)) {
111
                return 1;
112
            }
113
114
            return 0;
115
        });
116
117
        // Merge items.
118
        $result = [];
119
        $last = clone array_shift($dateRanges);
120
        while (($current = array_shift($dateRanges)) !== null) {
121
            if ($last->isBefore($current) && $last->end_date < $current->start_date) {
122
                $result[] = $last;
123
                $last = clone $current;
124
125
                continue;
126
            }
127
128
            $last->start_date = min($last->start_date, $current->start_date);
129
            if ($last->end_date === null || ($current->end_date !== null && $last->end_date < $current->end_date)) {
130
                $last->end_date = $current->end_date;
131
            }
132
        }
133
        $result[] = $last;
134
135
        return $result;
136
    }
137
138
    /**
139
     * Compute union of multiple sets of Date Ranges.
140
     *
141
     * This method computes union of multiple sets of Date Ranges.
142
     * The result is returned in normalized form.
143
     *
144
     * **Warning**: this method **does not** take `params` into account.
145
     *
146
     * @param \BEdita\Core\Model\Entity\DateRange[][] ...$dateRanges Set of Date Ranges.
147
     * @return \BEdita\Core\Model\Entity\DateRange[]
148
     */
149
    public static function union(...$dateRanges)
150
    {
151
        $dateRanges = array_merge(...$dateRanges);
152
153
        return static::normalize($dateRanges);
154
    }
155
156
    /**
157
     * Compute difference between two sets of Date Ranges.
158
     *
159
     * When computing complement of `$array1` with respect to `$array2`:
160
     *  - Date Ranges with `end_date = null` are treated as unit sets, all
161
     *    other Date Ranges are considered intervals.
162
     *  - complement of an interval with respect to another interval results
163
     *    in the difference of the two sets.
164
     *  - complement of an interval with respect to a unit set results in
165
     *    the interval unmodified.
166
     *  - complement of a unit sets with respect to an interval results in
167
     *    either the unit set unmodified if they are not overlapping, or in
168
     *    the empty set otherwise.
169
     *  - complement of a unit sets with respect to another unit set results
170
     *    in either the unit set unmodified if they are not the same, or in
171
     *    the empty set otherwise.
172
     *
173
     * **Warning**: this method does **not** take `params` into account.
174
     *
175
     * ### Example
176
     *
177
     * ```php
178
     * $array1 = [new DateRange(['start_date' => new FrozenTime('2017-01-01 00:00:00'), 'end_date' => new FrozenTime('2017-01-31 12:59:59')])];
179
     * $array2 = [new DateRange(['start_date' => new FrozenTime('2017-01-10 00:00:00'), 'end_date' => new FrozenTime('2017-01-19 12:59:59')])];
180
     *
181
     * $diff = DateRange::diff($array1, $array2);
182
     *
183
     * // $diff will now be equivalent to:
184
     * $diff = [
185
     *     new DateRange(['start_date' => new FrozenTime('2017-01-10 00:00:00'), 'end_date' => new FrozenTime('2017-01-10 00:00:00')]),
186
     *     new DateRange(['start_date' => new FrozenTime('2017-01-19 12:59:59'), 'end_date' => new FrozenTime('2017-01-19 12:59:59')]),
187
     * ];
188
     * ```
189
     *
190
     * @param \BEdita\Core\Model\Entity\DateRange[] $array1 First set of Date Ranges.
191
     * @param \BEdita\Core\Model\Entity\DateRange[] $array2 Second set of Date Ranges.
192
     * @return \BEdita\Core\Model\Entity\DateRange[]
193
     */
194
    public static function diff(array $array1, array $array2)
195
    {
196
        // Ensure arrays are normalized.
197
        $array1 = static::normalize($array1);
198
        $array2 = static::normalize($array2);
199
200
        $result = [];
201
        $dateRange = null;
202
        foreach ($array1 as $dateRange1) {
203
            if ($dateRange !== null) {
204
                $result[] = $dateRange;
205
            }
206
            $dateRange = clone $dateRange1;
207
208
            while (($dateRange2 = current($array2)) !== false) {
209
                if (
210
                    $dateRange->end_date === null
211
                    && $dateRange2->end_date === null
212
                    && $dateRange->start_date->getTimestamp() === $dateRange2->start_date->getTimestamp()
213
                ) {
214
                    // Unit sets match. Discard range.
215
                    $dateRange = null;
216
                    next($array2);
217
218
                    break;
219
                }
220
                if ($dateRange2->end_date === null || $dateRange2->isBefore($dateRange)) {
221
                    // Does not affect intersection.
222
                    next($array2);
223
224
                    continue;
225
                }
226
                if ($dateRange2->isAfter($dateRange)) {
227
                    // A step too far.
228
                    break;
229
                }
230
                if ($dateRange->start_date < $dateRange2->start_date) {
231
                    // Split the range.
232
                    $temp = clone $dateRange;
233
                    $temp->end_date = $dateRange2->start_date;
234
                    $result[] = $temp;
235
236
                    $dateRange->start_date = $dateRange2->start_date;
237
                }
238
                if ($dateRange->end_date < $dateRange2->end_date) {
239
                    // Discard range.
240
                    $dateRange = null;
241
242
                    break;
243
                }
244
245
                $dateRange->start_date = $dateRange2->end_date;
246
247
                next($array2);
248
            }
249
        }
250
251
        if ($dateRange !== null) {
252
            $result[] = $dateRange;
253
        }
254
255
        return $result;
256
    }
257
258
    /**
259
     * Check that all the Date Ranges passed as arguments are actually well formed.
260
     *
261
     * A "well formed" Date Range is an instance of class {@see \BEdita\Core\Model\Entity\DateRange}
262
     * whose field `start_date` is an instance of {@see Cake\I18n\Time} and field `end_date` is
263
     * either `null` or an instance of {@see Cake\I18n\Time}.
264
     *
265
     * @param array ...$dateRanges Date Ranges to check.
266
     * @return void
267
     * @throws \LogicException Throws an exception if a malformed Date Range is encountered.
268
     */
269
    public static function checkWellFormed(...$dateRanges)
270
    {
271
        $getType = function ($var) {
272
            if (!is_object($var)) {
273
                return gettype($var);
274
            }
275
276
            return get_class($var);
277
        };
278
279
        foreach ($dateRanges as $dateRange) {
280
            if (!($dateRange instanceof self)) {
281
                throw new \LogicException(
282
                    __d('bedita', 'Invalid Date Range entity class: expected "{0}", got "{1}"', static::class, $getType($dateRange))
283
                );
284
            }
285
286
            if (!($dateRange->start_date instanceof \DateTimeInterface)) {
287
                throw new \LogicException(
288
                    __d('bedita', 'Invalid "{0}": expected "{1}", got "{2}"', 'start_date', \DateTimeInterface::class, $getType($dateRange->start_date))
289
                );
290
            }
291
292
            if (!($dateRange->end_date instanceof \DateTimeInterface) && $dateRange->end_date !== null) {
293
                throw new \LogicException(
294
                    __d('bedita', 'Invalid "{0}": expected "{1}", got "{2}"', 'end_date', \DateTimeInterface::class, $getType($dateRange->end_date))
295
                );
296
            }
297
        }
298
    }
299
}
300