Passed
Push — master ( 94f321...811ae6 )
by Ondřej
02:56 queued 12s
created

TimeTz   A

Complexity

Total Complexity 23

Size/Duplication

Total Lines 226
Duplicated Lines 0 %

Importance

Changes 2
Bugs 0 Features 1
Metric Value
eloc 47
c 2
b 0
f 1
dl 0
loc 226
rs 10
wmc 23

14 Methods

Rating   Name   Duplication   Size   Complexity  
A getOffset() 0 3 1
A fromDateTime() 0 3 1
A fromParts() 0 3 1
B fromString() 0 39 8
A fromPartsStrict() 0 4 1
A __construct() 0 4 1
A occursBefore() 0 3 1
A getOffsetISOString() 0 6 2
A format() 0 11 2
A occursAfter() 0 3 1
A fromUnixTimestamp() 0 4 1
A toUnixTimestamp() 0 3 1
A toString() 0 3 1
A occursAt() 0 3 1
1
<?php
2
declare(strict_types=1);
3
namespace Ivory\Value;
4
5
/**
6
 * Timezone-aware representation of time of day (no date, just time).
7
 *
8
 * For a timezone-unaware time, see {@link Time}.
9
 *
10
 * The supported range for the time part is from `00:00:00` to `24:00:00`. Fractional seconds may be used.
11
 *
12
 * Note that just the offset from GMT is recorded with the time, not the timezone identifier. E.g., if the connection
13
 * is configured for the `Europe/Prague` timezone, PostgreSQL puts `+0100` or `+0200` on output (depending on whether
14
 * the daylight "savings" time is in effect). That differs from the {@link TimestampTz} type, which records the
15
 * timezone, not just its offset.
16
 *
17
 * The objects are {@link IEqualable}, which considers two time representations equal only if they have both the time
18
 * part and the timezone offset equal. The same logic is used for the PHP `==` operator. Behaviour of the PHP `<` and
19
 * `>` operators is undefined. To compare the physical time of two {@link TimeTz} objects, use the
20
 * {@link TimeTz::occursBefore()}, {@link TimeTz::occursAt()} and {@link TimeTz::occursAfter()} methods.
21
 *
22
 * The objects are immutable, i.e., operations always produce a new object.
23
 *
24
 * @see https://www.postgresql.org/docs/11/datatype-datetime.html
25
 */
26
class TimeTz extends TimeBase
27
{
28
    /** @var int difference to UTC in seconds; positive to the east of GMT, negative to the west of GMT */
29
    private $offset;
30
31
    /**
32
     * Creates a timezone-aware time object from a string containing the time and the timezone offset.
33
     *
34
     * The accepted format is `H:M[:S[.p]][offset]`, where `H` holds hours (0-24), `M` minutes (0-59), `S` seconds
35
     * (0-60), `p` fractional seconds, `offset` is the timezone offset written in the ISO 8601 format (e.g., `+2:00` or
36
     * `-800`). If the timezone offset is not given, the current offset is used.
37
     *
38
     * Note that, although 60 is accepted in the seconds part, it gets automatically converted to 0 of the next minute,
39
     * as neither PostgreSQL supports leap seconds.
40
     *
41
     * @param string $timeString
42
     * @return TimeTz
43
     * @throws \InvalidArgumentException on invalid input
44
     * @throws \OutOfRangeException when some of the parts is outside its range
45
     */
46
    public static function fromString(string $timeString): TimeTz
47
    {
48
        $re = '~^
49
                (\d+):                  # hours
50
                (\d+)                   # minutes
51
                (?::(\d+(?:\.\d*)?))?   # optional seconds, possibly with fractional part
52
                (?P<zone>               # optional timezone specification
53
                 Z|                     # ...either the "Z" letter or offset
54
                 (?P<offh>[-+]\d{1,2})  # ...or two digits for hours offset
55
                 (?::?(?P<offm>\d{2}))? # ...and possibly two digits for minutes offset, optionally separated by a colon
56
                )?
57
                $~x';
58
        if (!preg_match($re, $timeString, $m)) {
59
            throw new \InvalidArgumentException('$timeString');
60
        }
61
62
        $hour = $m[1];
63
        $min = $m[2];
64
        $sec = (!empty($m[3]) ? $m[3] : 0);
65
66
        self::checkTimeParts($hour, $min, $sec);
67
68
        if (!isset($m['zone'])) {
69
            try {
70
                $dateTime = new \DateTime();
71
            } catch (\Exception $e) {
72
                throw new \LogicException('Date/time error', 0, $e);
73
            }
74
            $offset = ($dateTime)->getOffset();
75
        } elseif ($m['zone'] == 'Z') {
76
            $offset = 0;
77
        } else {
78
            $offset = $m['offh'] * 60 * 60;
79
            if (isset($m['offm'])) {
80
                $offset += ($m['offh'] >= 0 ? 1 : -1) * $m['offm'] * 60;
81
            }
82
        }
83
84
        return new TimeTz($hour * 60 * 60 + $min * 60 + $sec, $offset);
85
    }
86
87
    /**
88
     * Creates a timezone-aware time object from the timezone offset and specified hours, minutes, and seconds.
89
     *
90
     * Any part exceeding its standard range overflows in the expected way to the higher part. E.g., it is possible to
91
     * pass 70 seconds, which results, in 1 minute 10 seconds. Moreover, the arithmetic also works for subtracting
92
     * negative minutes or seconds. Still, the overall time must fit between 00:00:00 and 24:00:00.
93
     *
94
     * The overflow rule applies to leap seconds as well as to any other value, i.e., 60 seconds get converted to 0 of
95
     * the next minute, as neither PostgreSQL supports leap seconds.
96
     *
97
     * @param int $hour
98
     * @param int $minute
99
     * @param int|float $second
100
     * @param int $offset the timezone offset of this time from the Greenwich Mean Time, in seconds;
101
     *                    positive for east of Greenwich, negative for west of Greenwich
102
     * @return TimeTz
103
     * @throws \OutOfRangeException when the resulting time underruns 00:00:00 or exceeds 24:00:00
104
     */
105
    public static function fromParts(int $hour, int $minute, $second, int $offset): TimeTz
106
    {
107
        return new TimeTz(self::partsToSec($hour, $minute, $second), $offset);
108
    }
109
110
    /**
111
     * Creates a timezone-aware time object from the timezone offset and specified hours, minutes, and seconds with
112
     * range checks for each of them.
113
     *
114
     * Note that, although 60 is accepted in the seconds part, it gets automatically converted to 0 of the next minute,
115
     * as neither PostgreSQL supports leap seconds. In this case it is possible to get time even greater than 24:00:00
116
     * (but still less than 24:00:01).
117
     *
118
     * @param int $hour 0-24, but when 24, the others must be zero
119
     * @param int $minute 0-59
120
     * @param int|float $second greater than or equal to 0, less than 61
121
     * @param int $offset the timezone offset of this time from the Greenwich Mean Time, in seconds;
122
     *                    positive for east of Greenwich, negative for west of Greenwich
123
     * @return TimeTz
124
     * @throws \OutOfRangeException when some of the parts is outside its range
125
     */
126
    public static function fromPartsStrict(int $hour, int $minute, $second, int $offset): TimeTz
127
    {
128
        $sec = self::partsToSecStrict($hour, $minute, $second);
129
        return new TimeTz($sec, $offset);
130
    }
131
132
    /**
133
     * Extracts the time part and timezone offset from a {@link \DateTime} or {@link \DateTimeImmutable} object.
134
     *
135
     * @param \DateTimeInterface $dateTime
136
     * @return TimeTz
137
     */
138
    public static function fromDateTime(\DateTimeInterface $dateTime): TimeTz
139
    {
140
        return self::fromString($dateTime->format('H:i:s.uP'));
141
    }
142
143
    /**
144
     * Extracts the time part from a UNIX timestamp as the time in the UTC timezone.
145
     *
146
     * Negative timestamps are supported. E.g., timestamp `-30.1` results in time `23:59:29.9`.
147
     *
148
     * Note there is one exception: the timestamp `1970-01-02 00:00:00 UTC` gets extracted as time `24:00:00` so that
149
     * there is symmetry with {@link TimeTz::toUnixTimestamp()}. Other timestamps are processed as expected, i.e., the
150
     * day part gets truncated and the result being less than `24:00:00`.
151
     *
152
     * @param int|float $timestamp
153
     * @return TimeTz
154
     */
155
    public static function fromUnixTimestamp($timestamp): TimeTz
156
    {
157
        $sec = self::cutUnixTimestampToSec($timestamp);
158
        return new TimeTz($sec, 0);
159
    }
160
161
    /**
162
     * @internal Only for the purpose of Ivory itself.
163
     * @param int|float $sec
164
     * @param int $offset
165
     */
166
    protected function __construct($sec, int $offset)
167
    {
168
        parent::__construct($sec);
169
        $this->offset = $offset;
170
    }
171
172
    /**
173
     * @return int the timezone offset of this time from the Greenwich Mean Time, in seconds;
174
     *             positive for east of Greenwich, negative for west of Greenwich
175
     */
176
    final public function getOffset(): int
177
    {
178
        return $this->offset;
179
    }
180
181
    /**
182
     * @return string the timezone offset of this time from the Greenwich Mean Time formatted according to ISO 8601
183
     *                  using no delimiter, e.g., <tt>+0200</tt> or <tt>-0830</tt>
184
     */
185
    final public function getOffsetISOString(): string
186
    {
187
        return sprintf('%s%02d%02d',
188
            ($this->offset >= 0 ? '+' : '-'),
189
            abs($this->offset) / (60 * 60),
190
            (abs($this->offset) / 60) % 60
191
        );
192
    }
193
194
    public function toUnixTimestamp($date = null)
195
    {
196
        return parent::toUnixTimestamp($date) - $this->offset;
197
    }
198
199
    /**
200
     * @param TimeTz $other
201
     * @return bool whether this and the other time happen in the exact same moment
202
     */
203
    public function occursAt(TimeTz $other): bool
204
    {
205
        return ($this->toUnixTimestamp() == $other->toUnixTimestamp());
206
    }
207
208
    /**
209
     * @param TimeTz $other
210
     * @return bool whether this time happens before the given other time
211
     */
212
    public function occursBefore(TimeTz $other): bool
213
    {
214
        return ($this->toUnixTimestamp() < $other->toUnixTimestamp());
215
    }
216
217
    /**
218
     * @param TimeTz $other
219
     * @return bool whether this time happens after the given other time
220
     */
221
    public function occursAfter(TimeTz $other): bool
222
    {
223
        return ($this->toUnixTimestamp() > $other->toUnixTimestamp());
224
    }
225
226
    /** @noinspection PhpMissingParentCallCommonInspection */
227
    /**
228
     * @param string $timeFmt the format string as accepted by {@link date()}
229
     * @return string the time formatted according to <tt>$timeFmt</tt>
230
     */
231
    public function format(string $timeFmt): string
232
    {
233
        $str = $this->toString();
234
        try {
235
            $ts = new \DateTime($str);
236
            // OPT: \DateTime::createFromFormat() is supposed to be twice as fast as new \DateTime()
237
        } catch (\Exception $e) {
238
            throw new \LogicException('Date/time error', 0, $e);
239
        }
240
        $ts->setDate(1970, 1, 1);
241
        return $ts->format($timeFmt);
242
    }
243
244
    /**
245
     * @return string the ISO representation of this time, in format <tt>HH:MM:SS[.p](+|-)hhmm</tt>, where <tt>hh</tt>
246
     *                  and <tt>mm</tt> represent the timezone offset in hours and minutes, respectively;
247
     *                the fractional seconds part is only used if non-zero
248
     */
249
    public function toString(): string
250
    {
251
        return parent::toString() . $this->getOffsetISOString();
252
    }
253
}
254