|
1
|
|
|
<?php |
|
2
|
|
|
namespace Ivory\Value; |
|
3
|
|
|
|
|
4
|
|
|
/** |
|
5
|
|
|
* Timezone-aware representation of date and time according to the |
|
6
|
|
|
* {@link https://en.wikipedia.org/wiki/Proleptic_Gregorian_calendar proleptic} Gregorian calendar. |
|
7
|
|
|
* |
|
8
|
|
|
* For a timezone-unaware date/time, see {@link Timestamp}. |
|
9
|
|
|
* |
|
10
|
|
|
* As in PostgreSQL, there are two special date/time values, `-infinity` and `infinity`, representing a date/time |
|
11
|
|
|
* respectively before or after any other date/time. There are special factory methods |
|
12
|
|
|
* {@link TimestampTz::minusInfinity()} and {@link TimestampTz::infinity()} for getting these values. |
|
13
|
|
|
* |
|
14
|
|
|
* All the operations work correctly beyond the UNIX timestamp range bounded by 32bit integers, i.e., it is no problem |
|
15
|
|
|
* calculating with year 12345, for example. |
|
16
|
|
|
* |
|
17
|
|
|
* Note the date/time value is immutable, i.e., once constructed, its value cannot be changed. |
|
18
|
|
|
* |
|
19
|
|
|
* @see http://www.postgresql.org/docs/9.4/static/datetime-units-history.html |
|
20
|
|
|
*/ |
|
21
|
|
|
class TimestampTz extends TimestampBase |
|
22
|
|
|
{ |
|
23
|
|
|
/** |
|
24
|
|
|
* @return TimestampTz date/time representing the current moment, with precision to microseconds (or, more |
|
25
|
|
|
* specifically, with the precision supported by the hosting platform) |
|
26
|
|
|
*/ |
|
27
|
|
View Code Duplication |
public static function now(): TimestampTz |
|
|
|
|
|
|
28
|
|
|
{ |
|
29
|
|
|
if (PHP_VERSION_ID >= 70100) { |
|
30
|
|
|
return new TimestampTz(0, new \DateTimeImmutable('now')); |
|
31
|
|
|
} |
|
32
|
|
|
else { |
|
33
|
|
|
// up to PHP 7.0, new \DateTimeImmutable('now') had only precision up to seconds |
|
34
|
|
|
list($micro, $sec) = explode(' ', microtime()); |
|
35
|
|
|
$microFrac = substr($micro, 1); // cut off the whole part (always a zero) |
|
36
|
|
|
$inputStr = date('Y-m-d\TH:i:s', $sec) . $microFrac; |
|
37
|
|
|
return new TimestampTz(0, new \DateTimeImmutable($inputStr)); |
|
38
|
|
|
} |
|
39
|
|
|
} |
|
40
|
|
|
|
|
41
|
|
|
/** |
|
42
|
|
|
* Creates a date/time from an ISO 8601 string. |
|
43
|
|
|
* |
|
44
|
|
|
* As ISO 8601, the input shall be formatted as, e.g., `2016-03-30T18:30:42Z`. This method also accepts a space as |
|
45
|
|
|
* the date/time separator instead of the `T` letter. Timezone information may be omitted, in which case the current |
|
46
|
|
|
* timezone is used. As ISO 8601 says, either `Z` or `±hh[[:]mm]` is expected as the timezone specification. |
|
47
|
|
|
* |
|
48
|
|
|
* Years beyond 4 digits are supported, i.e., `'12345-01-30'` is a valid input, representing a date of year 12345. |
|
49
|
|
|
* |
|
50
|
|
|
* As defined by ISO 8601, years before Christ are expected to be represented by numbers prefixed with a minus sign, |
|
51
|
|
|
* `0000` representing year 1 BC, `-0001` standing for year 2 BC, etc. |
|
52
|
|
|
* |
|
53
|
|
|
* Years anno Domini, i.e., the positive years, may optionally be prefixed with a plus sign. |
|
54
|
|
|
* |
|
55
|
|
|
* @param string $isoDateTimeString |
|
56
|
|
|
* @return TimestampTz |
|
57
|
|
|
* @throws \InvalidArgumentException on invalid input |
|
58
|
|
|
*/ |
|
59
|
|
|
public static function fromISOString(string $isoDateTimeString): TimestampTz |
|
60
|
|
|
{ |
|
61
|
|
|
$dt = self::isoStringToDateTime($isoDateTimeString); |
|
62
|
|
|
return new TimestampTz(0, $dt); |
|
63
|
|
|
} |
|
64
|
|
|
|
|
65
|
|
|
/** |
|
66
|
|
|
* @param \DateTimeInterface $dateTime |
|
67
|
|
|
* @return TimestampTz date/time represented by the given <tt>$dateTime</tt> object |
|
68
|
|
|
*/ |
|
69
|
|
|
public static function fromDateTime(\DateTimeInterface $dateTime): TimestampTz |
|
70
|
|
|
{ |
|
71
|
|
|
if ($dateTime instanceof \DateTimeImmutable) { |
|
72
|
|
|
return new TimestampTz(0, $dateTime); |
|
73
|
|
|
} elseif ($dateTime instanceof \DateTime) { |
|
74
|
|
|
return new TimestampTz(0, \DateTimeImmutable::createFromMutable($dateTime)); |
|
75
|
|
|
} else { |
|
76
|
|
|
// there should not be any other implementation of \DateTimeInterface, but PHP is not that predictable |
|
77
|
|
|
return self::fromParts( |
|
78
|
|
|
$dateTime->format('Y'), |
|
79
|
|
|
$dateTime->format('n'), |
|
80
|
|
|
$dateTime->format('j'), |
|
81
|
|
|
$dateTime->format('G'), |
|
82
|
|
|
$dateTime->format('i'), |
|
83
|
|
|
$dateTime->format('s') + ($dateTime->format('u') ? $dateTime->format('u') / 1000000 : 0), |
|
84
|
|
|
$dateTime->format('e') |
|
85
|
|
|
); |
|
86
|
|
|
} |
|
87
|
|
|
} |
|
88
|
|
|
|
|
89
|
|
|
/** |
|
90
|
|
|
* Creates a date/time from the given year, month, day, hour, minute, second, and timezone. |
|
91
|
|
|
* |
|
92
|
|
|
* Invalid combinations of months and days, as well as hours, minutes and seconds outside their standard ranges, |
|
93
|
|
|
* are accepted similarly to the {@link mktime()} function. |
|
94
|
|
|
* E.g., `$year 2015, $month 14, $day 32, $hour 25, $minute -2, second 70` will be silently converted to |
|
95
|
|
|
* `2016-03-04 00:59:10`. If this is unacceptable, use the strict variant {@link TimestampTz::fromPartsStrict()} |
|
96
|
|
|
* instead. |
|
97
|
|
|
* |
|
98
|
|
|
* Years before Christ shall be represented by negative numbers. E.g., year 42 BC shall be given as -42. |
|
99
|
|
|
* |
|
100
|
|
|
* Note that, in the Gregorian calendar, there is no year 0. Thus, `$year == 0` will be rejected with an |
|
101
|
|
|
* `\InvalidArgumentException`. |
|
102
|
|
|
* |
|
103
|
|
|
* @param int $year |
|
104
|
|
|
* @param int $month |
|
105
|
|
|
* @param int $day |
|
106
|
|
|
* @param int $hour |
|
107
|
|
|
* @param int $minute |
|
108
|
|
|
* @param int|float $second |
|
109
|
|
|
* @param \DateTimeZone|string|int $timezone either the \DateTimeZone object, or |
|
110
|
|
|
* a string containing the timezone name or its abbreviation (e.g., |
|
111
|
|
|
* <tt>Europe/Prague</tt> or <tt>CEST</tt>) or ISO-style offset from GMT |
|
112
|
|
|
* (e.g., <tt>+02:00</tt> or <tt>+0200</tt> or <tt>+02</tt>), or |
|
113
|
|
|
* an integer specifying the offset from GMT in seconds |
|
114
|
|
|
* @return TimestampTz |
|
115
|
|
|
* @throws \InvalidArgumentException if <tt>$year</tt> is zero or <tt>$timezone</tt> is not recognized by PHP |
|
116
|
|
|
*/ |
|
117
|
|
|
public static function fromParts( |
|
118
|
|
|
int $year, |
|
119
|
|
|
int $month, |
|
120
|
|
|
int $day, |
|
121
|
|
|
int $hour, |
|
122
|
|
|
int $minute, |
|
123
|
|
|
$second, |
|
124
|
|
|
$timezone |
|
125
|
|
|
): TimestampTz { |
|
126
|
|
|
if ($year == 0) { |
|
127
|
|
|
throw new \InvalidArgumentException('$year zero is undefined'); |
|
128
|
|
|
} |
|
129
|
|
|
|
|
130
|
|
|
$tz = self::parseTimezone($timezone); |
|
131
|
|
|
$z = ($year > 0 ? $year : $year + 1); |
|
132
|
|
|
|
|
133
|
|
|
if (self::inRanges($month, $day, $hour, $minute, $second)) { |
|
134
|
|
|
// works even for months without 31 days |
|
135
|
|
|
$dt = self::isoStringToDateTime( |
|
136
|
|
|
sprintf( |
|
137
|
|
|
'%s%04d-%02d-%02d %02d:%02d:%s', |
|
138
|
|
|
($z < 0 ? '-' : ''), abs($z), $month, $day, $hour, $minute, self::floatToTwoPlaces($second) |
|
139
|
|
|
), |
|
140
|
|
|
$tz |
|
141
|
|
|
); |
|
142
|
|
|
return new TimestampTz(0, $dt); |
|
143
|
|
|
} else { |
|
144
|
|
|
$dt = self::isoStringToDateTime( |
|
145
|
|
|
sprintf('%s%04d-01-01 00:00:00', ($z < 0 ? '-' : ''), abs($z)), |
|
146
|
|
|
$tz |
|
147
|
|
|
); |
|
148
|
|
|
return (new TimestampTz(0, $dt)) |
|
149
|
|
|
->addParts(0, $month - 1, $day - 1, $hour, $minute, $second); |
|
150
|
|
|
} |
|
151
|
|
|
} |
|
152
|
|
|
|
|
153
|
|
|
/** |
|
154
|
|
|
* Creates a date/time from the given year, month, day, hour, minute, second, and timezone while strictly checking |
|
155
|
|
|
* for the validity of the data. |
|
156
|
|
|
* |
|
157
|
|
|
* For a friendlier variant, accepting even out-of-range values (doing the adequate calculations), see |
|
158
|
|
|
* {@link TimestampTz::fromParts()}. |
|
159
|
|
|
* |
|
160
|
|
|
* Years before Christ shall be represented by negative numbers. E.g., year 42 BC shall be given as -42. |
|
161
|
|
|
* |
|
162
|
|
|
* Note that, in the Gregorian calendar, there is no year 0. Thus, `$year == 0` will be rejected with an |
|
163
|
|
|
* `\InvalidArgumentException`. |
|
164
|
|
|
* |
|
165
|
|
|
* @param int $year |
|
166
|
|
|
* @param int $month |
|
167
|
|
|
* @param int $day |
|
168
|
|
|
* @param int $hour |
|
169
|
|
|
* @param int $minute |
|
170
|
|
|
* @param int|float $second |
|
171
|
|
|
* @param \DateTimeZone|string|int $timezone either the \DateTimeZone object, or |
|
172
|
|
|
* a string containing the timezone name or its abbreviation (e.g., |
|
173
|
|
|
* <tt>Europe/Prague</tt> or <tt>CEST</tt>) or ISO-style offset from GMT |
|
174
|
|
|
* (e.g., <tt>+02:00</tt> or <tt>+0200</tt> or <tt>+02</tt>), or |
|
175
|
|
|
* an integer specifying the offset from GMT in seconds |
|
176
|
|
|
* @return TimestampTz |
|
177
|
|
|
* @throws \InvalidArgumentException if <tt>$year</tt> is zero or <tt>$timezone</tt> is not recognized by PHP |
|
178
|
|
|
*/ |
|
179
|
|
|
public static function fromPartsStrict( |
|
180
|
|
|
int $year, |
|
181
|
|
|
int $month, |
|
182
|
|
|
int $day, |
|
183
|
|
|
int $hour, |
|
184
|
|
|
int $minute, |
|
185
|
|
|
$second, |
|
186
|
|
|
$timezone |
|
187
|
|
|
): TimestampTz { |
|
188
|
|
|
self::assertRanges($year, $month, $day, $hour, $minute, $second); |
|
189
|
|
|
$tz = self::parseTimezone($timezone); |
|
190
|
|
|
$z = ($year > 0 ? $year : $year + 1); |
|
191
|
|
|
|
|
192
|
|
|
$dt = self::isoStringToDateTime( |
|
193
|
|
|
sprintf( |
|
194
|
|
|
'%s%04d-%02d-%02d %02d:%02d:%s', |
|
195
|
|
|
($z < 0 ? '-' : ''), abs($z), $month, $day, $hour, $minute, self::floatToTwoPlaces($second) |
|
196
|
|
|
), |
|
197
|
|
|
$tz |
|
198
|
|
|
); |
|
199
|
|
|
if ($dt->format('j') != ($day + ($hour == 24 ? 1 : 0))) { |
|
200
|
|
|
throw new \OutOfRangeException('$day out of range'); |
|
201
|
|
|
} |
|
202
|
|
|
|
|
203
|
|
|
return new TimestampTz(0, $dt); |
|
204
|
|
|
} |
|
205
|
|
|
|
|
206
|
|
|
private static function parseTimezone($timezone): \DateTimeZone |
|
207
|
|
|
{ |
|
208
|
|
|
if ($timezone instanceof \DateTimeZone) { |
|
209
|
|
|
return $timezone; |
|
210
|
|
|
} |
|
211
|
|
|
|
|
212
|
|
|
if (filter_var($timezone, FILTER_VALIDATE_INT) !== false) { |
|
213
|
|
|
$tzSpec = ($timezone >= 0 ? '+' : '-') . gmdate('H:i', abs($timezone)); |
|
214
|
|
|
} elseif (preg_match('~^([^:]+:\d+):\d+$~', $timezone, $m)) { |
|
215
|
|
|
$tzSpec = $m[1]; |
|
216
|
|
|
$msg = "PHP's DateTimeZone is unable to represent GMT offsets with precision to seconds. " |
|
217
|
|
|
. "Cutting '$timezone' to '$tzSpec'"; |
|
218
|
|
|
trigger_error($msg, E_USER_WARNING); |
|
219
|
|
|
} else { |
|
220
|
|
|
$tzSpec = $timezone; |
|
221
|
|
|
} |
|
222
|
|
|
|
|
223
|
|
|
try { |
|
224
|
|
|
return new \DateTimeZone($tzSpec); |
|
225
|
|
|
} catch (\Exception $e) { |
|
226
|
|
|
throw new \InvalidArgumentException('$timezone', 0, $e); |
|
227
|
|
|
} |
|
228
|
|
|
} |
|
229
|
|
|
|
|
230
|
|
|
final protected function getISOFormat(): string |
|
231
|
|
|
{ |
|
232
|
|
|
return 'Y-m-d\TH:i:s' . ($this->dt->format('u') ? '.u' : '') . 'O'; |
|
233
|
|
|
} |
|
234
|
|
|
|
|
235
|
|
|
/** |
|
236
|
|
|
* @return string the timezone offset of this time from the Greenwich Mean Time formatted according to ISO 8601 |
|
237
|
|
|
* using no delimiter, e.g., <tt>+0200</tt> or <tt>-0830</tt> |
|
238
|
|
|
*/ |
|
239
|
|
|
public function getOffsetISOString(): string |
|
240
|
|
|
{ |
|
241
|
|
|
return $this->dt->format('O'); |
|
242
|
|
|
} |
|
243
|
|
|
|
|
244
|
|
|
/** |
|
245
|
|
|
* @return mixed[]|null a list of seven items: year, month, day, hours, minutes, seconds, and timezone of this |
|
246
|
|
|
* date/time, all of which are integers except the seconds part, which might be a float if |
|
247
|
|
|
* containing the fractional part, and the timezone part, which is a {@link \DateTimeZone} |
|
248
|
|
|
* object; |
|
249
|
|
|
* <tt>null</tt> iff the date/time is not finite |
|
250
|
|
|
*/ |
|
251
|
|
View Code Duplication |
public function toParts() |
|
|
|
|
|
|
252
|
|
|
{ |
|
253
|
|
|
if ($this->inf) { |
|
254
|
|
|
return null; |
|
255
|
|
|
} else { |
|
256
|
|
|
$y = (int)$this->dt->format('Y'); |
|
257
|
|
|
$u = $this->dt->format('u'); |
|
258
|
|
|
return [ |
|
259
|
|
|
($y > 0 ? $y : $y - 1), |
|
260
|
|
|
(int)$this->dt->format('n'), |
|
261
|
|
|
(int)$this->dt->format('j'), |
|
262
|
|
|
(int)$this->dt->format('G'), |
|
263
|
|
|
(int)$this->dt->format('i'), |
|
264
|
|
|
$this->dt->format('s') + ($u ? $u / 1000000 : 0), |
|
265
|
|
|
$this->dt->getTimezone(), |
|
266
|
|
|
]; |
|
267
|
|
|
} |
|
268
|
|
|
} |
|
269
|
|
|
} |
|
270
|
|
|
|
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.