Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
93.86% |
107 / 114 |
|
73.33% |
11 / 15 |
CRAP | |
0.00% |
0 / 1 |
ConvertibleTimestamp | |
93.86% |
107 / 114 |
|
73.33% |
11 / 15 |
62.89 | |
0.00% |
0 / 1 |
time | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
2 | |||
microtime | |
100.00% |
10 / 10 |
|
100.00% |
1 / 1 |
3 | |||
setFakeTime | |
93.75% |
15 / 16 |
|
0.00% |
0 / 1 |
7.01 | |||
__construct | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
2 | |||
setTimestamp | |
95.24% |
40 / 42 |
|
0.00% |
0 / 1 |
25 | |||
convert | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
2 | |||
now | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
getTimestamp | |
100.00% |
18 / 18 |
|
100.00% |
1 / 1 |
8 | |||
__toString | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
diff | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
add | |
66.67% |
4 / 6 |
|
0.00% |
0 / 1 |
3.33 | |||
sub | |
66.67% |
4 / 6 |
|
0.00% |
0 / 1 |
3.33 | |||
setTimezone | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
2 | |||
getTimezone | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
format | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 |
1 | <?php |
2 | /** |
3 | * Timestamp |
4 | * |
5 | * Copyright (C) 2012 Tyler Romeo <tylerromeo@gmail.com> |
6 | * |
7 | * This program is free software; you can redistribute it and/or modify |
8 | * it under the terms of the GNU General Public License as published by |
9 | * the Free Software Foundation; either version 2 of the License, or |
10 | * (at your option) any later version. |
11 | * |
12 | * This program is distributed in the hope that it will be useful, |
13 | * but WITHOUT ANY WARRANTY; without even the implied warranty of |
14 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
15 | * GNU General Public License for more details. |
16 | * |
17 | * You should have received a copy of the GNU General Public License along |
18 | * with this program; if not, write to the Free Software Foundation, Inc., |
19 | * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. |
20 | * http://www.gnu.org/copyleft/gpl.html |
21 | * |
22 | * @file |
23 | * @author Tyler Romeo <tylerromeo@gmail.com> |
24 | */ |
25 | |
26 | namespace Wikimedia\Timestamp; |
27 | |
28 | use DateInterval; |
29 | use DateTime; |
30 | use DateTimeZone; |
31 | use Exception; |
32 | use InvalidArgumentException; |
33 | use ValueError; |
34 | |
35 | /** |
36 | * Library for creating, parsing, and converting timestamps. |
37 | */ |
38 | class ConvertibleTimestamp { |
39 | /** |
40 | * Standard gmdate() formats for the different timestamp types. |
41 | * @var string[] |
42 | */ |
43 | private static $formats = [ |
44 | TS_UNIX => 'U', |
45 | TS_MW => 'YmdHis', |
46 | TS_DB => 'Y-m-d H:i:s', |
47 | TS_ISO_8601 => 'Y-m-d\TH:i:s\Z', |
48 | TS_ISO_8601_BASIC => 'Ymd\THis\Z', |
49 | // This shouldn't ever be used, but is included for completeness |
50 | TS_EXIF => 'Y:m:d H:i:s', |
51 | TS_RFC2822 => 'D, d M Y H:i:s', |
52 | // Was 'd-M-y h.i.s A' . ' +00:00' before r51500 |
53 | TS_ORACLE => 'd-m-Y H:i:s.u', |
54 | // Formerly 'Y-m-d H:i:s' . ' GMT' |
55 | TS_POSTGRES => 'Y-m-d H:i:s+00', |
56 | TS_UNIX_MICRO => 'U.u', |
57 | ]; |
58 | |
59 | /** |
60 | * Regexes for setTimestamp(). Named capture groups correspond to format codes for |
61 | * DateTime::createFromFormat(). Unnamed groups are ignored. |
62 | * @var string[] |
63 | */ |
64 | private static $regexes = [ |
65 | // 'TS_DB' => subset of TS_ISO_8601 (with no 'T') |
66 | 'TS_MW' => '/^(?<Y>\d{4})(?<m>\d\d)(?<d>\d\d)(?<H>\d\d)(?<i>\d\d)(?<s>\d\d)$/D', |
67 | 'TS_ISO_8601' => |
68 | '/^(?<Y>\d{4})-(?<m>\d{2})-(?<d>\d{2})[T ]' . |
69 | '(?<H>\d{2}):(?<i>\d{2}):(?<s>\d{2})(?:[.,](?<u>\d{1,6}))?' . |
70 | '(?<O>Z|[+\-]\d{2}(?::?\d{2})?)?$/', |
71 | 'TS_ISO_8601_BASIC' => |
72 | '/^(?<Y>\d{4})(?<m>\d{2})(?<d>\d{2})T(?<H>\d{2})(?<i>\d{2})(?<s>\d{2})(?:[.,](?<u>\d{1,6}))?' . |
73 | '(?<O>Z|[+\-]\d{2}(?::?\d{2})?)?$/', |
74 | 'TS_UNIX' => '/^(?<U>-?\d{1,13})$/D', |
75 | 'TS_UNIX_MICRO' => '/^(?<U>-?\d{1,13})\.(?<u>\d{1,6})$/D', |
76 | 'TS_ORACLE' => |
77 | '/^(?<d>\d{2})-(?<m>\d{2})-(?<Y>\d{4}) (?<H>\d{2}):(?<i>\d{2}):(?<s>\d{2})\.(?<u>\d{6})$/', |
78 | // TS_POSTGRES is almost redundant to TS_ISO_8601 (with no 'T'), but accepts a space in place of |
79 | // a `+` before the timezone. |
80 | 'TS_POSTGRES' => |
81 | '/^(?<Y>\d{4})-(?<m>\d\d)-(?<d>\d\d) (?<H>\d\d):(?<i>\d\d):(?<s>\d\d)(?:\.(?<u>\d{1,6}))?' . |
82 | '(?<O>[\+\- ]\d\d)$/', |
83 | 'old TS_POSTGRES' => |
84 | '/^(?<Y>\d{4})-(?<m>\d\d)-(?<d>\d\d) (?<H>\d\d):(?<i>\d\d):(?<s>\d\d)(?:\.(?<u>\d{1,6}))? GMT$/', |
85 | 'TS_EXIF' => '/^(?<Y>\d{4}):(?<m>\d\d):(?<d>\d\d) (?<H>\d\d):(?<i>\d\d):(?<s>\d\d)$/D', |
86 | |
87 | 'TS_RFC2822' => |
88 | # Day of the week |
89 | '/^[ \t\r\n]*(?:(?<D>[A-Z][a-z]{2}),[ \t\r\n]*)?' . |
90 | # dd Mon yyyy |
91 | '(?<d>\d\d?)[ \t\r\n]+(?<M>[A-Z][a-z]{2})[ \t\r\n]+(?<Y>\d{2,})' . |
92 | # hh:mm:ss |
93 | '[ \t\r\n]+(?<H>\d\d)[ \t\r\n]*:[ \t\r\n]*(?<i>\d\d)[ \t\r\n]*:[ \t\r\n]*(?<s>\d\d)' . |
94 | # zone, optional for hysterical raisins |
95 | '(?:[ \t\r\n]+(?<O>[+-]\d{4}|UT|GMT|[ECMP][SD]T|[A-IK-Za-ik-z]))?' . |
96 | # optional trailing comment |
97 | # See http://www.squid-cache.org/mail-archive/squid-users/200307/0122.html / r77171 |
98 | '(?:[ \t\r\n]*;|$)/S', |
99 | |
100 | 'TS_RFC850' => |
101 | '/^(?<D>[A-Z][a-z]{5,8}), (?<d>\d\d)-(?<M>[A-Z][a-z]{2})-(?<y>\d{2}) ' . |
102 | '(?<H>\d\d):(?<i>\d\d):(?<s>\d\d)' . |
103 | # timezone optional for hysterical raisins. RFC just says "worldwide time zone abbreviations". |
104 | # https://en.wikipedia.org/wiki/List_of_time_zone_abbreviations lists strings of up to 5 |
105 | # uppercase letters. PHP 7.2's DateTimeZone::listAbbreviations() lists strings of up to 4 |
106 | # letters. |
107 | '(?: (?<O>[+\-]\d{2}(?::?\d{2})?|[A-Z]{1,5}))?$/', |
108 | |
109 | 'asctime' => '/^(?<D>[A-Z][a-z]{2}) (?<M>[A-Z][a-z]{2}) +(?<d>\d{1,2}) ' . |
110 | '(?<H>\d\d):(?<i>\d\d):(?<s>\d\d) (?<Y>\d{4})\s*$/', |
111 | ]; |
112 | |
113 | /** |
114 | * @var callback|null |
115 | * @see setFakeTime() |
116 | */ |
117 | protected static $fakeTimeCallback = null; |
118 | |
119 | /** |
120 | * Get the current time in the same form that PHP's built-in time() function uses. |
121 | * |
122 | * This is used by now() through setTimestamp( false ) instead of the built in time() function. |
123 | * The output of this method can be overwritten for testing purposes by calling setFakeTime(). |
124 | * |
125 | * @return int UNIX epoch |
126 | */ |
127 | public static function time() { |
128 | return static::$fakeTimeCallback ? (int)call_user_func( static::$fakeTimeCallback ) : \time(); |
129 | } |
130 | |
131 | /** |
132 | * Get the current time as seconds since the epoch, with sub-second precision. |
133 | * This is equivalent to calling PHP's built-in microtime() function with $as_float = true. |
134 | * The exact precision depends on the underlying operating system. |
135 | * |
136 | * Subsequent calls to microtime() are very unlikely to return the same value twice, |
137 | * and the values returned should be increasing. But there is no absolute guarantee |
138 | * of either of these properties. |
139 | * |
140 | * The output of this method can be overwritten for testing purposes by calling setFakeTime(). |
141 | * In that case, microtime() will use the return value of time(), with a monotonic counter |
142 | * used to make the return value of subsequent calls different from each other by a fraction |
143 | * of a second. |
144 | * |
145 | * @return float Seconds since the epoch |
146 | */ |
147 | public static function microtime(): float { |
148 | static $fakeSecond = 0; |
149 | static $fakeOffset = 0.0; |
150 | |
151 | if ( static::$fakeTimeCallback ) { |
152 | $sec = static::time(); |
153 | |
154 | // Use the fake time returned by time(), but add a microsecond each |
155 | // time this method is called, so subsequent calls to this method |
156 | // never return the same value. Reset the counter when the time |
157 | // returned by time() is different from the value it returned |
158 | // previously. |
159 | if ( $sec !== $fakeSecond ) { |
160 | $fakeSecond = $sec; |
161 | $fakeOffset = 0.0; |
162 | } else { |
163 | $fakeOffset++; |
164 | } |
165 | |
166 | return $fakeSecond + $fakeOffset * 0.000001; |
167 | } else { |
168 | return microtime( true ); |
169 | } |
170 | } |
171 | |
172 | /** |
173 | * Set a fake time value or clock callback. |
174 | * |
175 | * @param callable|ConvertibleTimestamp|string|int|false $fakeTime a fixed time given as a string, |
176 | * or as a number representing seconds since the UNIX epoch; or a callback that returns an int. |
177 | * or false to disable fake time and go back to real time. |
178 | * @param int|float $step The number of seconds by which to increment the clock each time |
179 | * the time() method is called. Must not be smaller than zero. |
180 | * Ignored if $fakeTime is a callback or false. |
181 | * |
182 | * @return callable|null the previous fake time callback, if any. |
183 | * |
184 | * @phan-param callable():int|ConvertibleTimestamp|string|int|false $fakeTime |
185 | */ |
186 | public static function setFakeTime( $fakeTime, $step = 0 ) { |
187 | if ( $fakeTime instanceof ConvertibleTimestamp ) { |
188 | $fakeTime = (int)$fakeTime->getTimestamp(); |
189 | } |
190 | |
191 | if ( is_string( $fakeTime ) ) { |
192 | $fakeTime = (int)static::convert( TS_UNIX, $fakeTime ); |
193 | } |
194 | |
195 | if ( is_int( $fakeTime ) ) { |
196 | $clock = $fakeTime; |
197 | $fakeTime = static function () use ( &$clock, $step ) { |
198 | $t = $clock; |
199 | $clock += $step; |
200 | return (int)$t; |
201 | }; |
202 | } |
203 | |
204 | if ( $fakeTime && !is_callable( $fakeTime ) ) { |
205 | throw new InvalidArgumentException( 'Bad fake time' ); |
206 | } |
207 | |
208 | $old = static::$fakeTimeCallback; |
209 | static::$fakeTimeCallback = $fakeTime ?: null; |
210 | return $old; |
211 | } |
212 | |
213 | /** |
214 | * The actual timestamp being wrapped (DateTime object). |
215 | * @var DateTime |
216 | */ |
217 | public $timestamp; |
218 | |
219 | /** |
220 | * Make a new timestamp and set it to the specified time, |
221 | * or the current time if unspecified. |
222 | * |
223 | * @param string|int|float|null|false|DateTime $timestamp Timestamp to set. |
224 | * If any falsy value is provided, the timestamp uses the current time instead. |
225 | * @throws TimestampException |
226 | */ |
227 | public function __construct( $timestamp = false ) { |
228 | if ( $timestamp instanceof DateTime ) { |
229 | $this->timestamp = $timestamp; |
230 | } else { |
231 | $this->setTimestamp( $timestamp ); |
232 | } |
233 | } |
234 | |
235 | /** |
236 | * Set the timestamp to the specified time, or the current time if unspecified. |
237 | * |
238 | * Parse the given timestamp into either a DateTime object or a Unix timestamp, |
239 | * and then store it. |
240 | * |
241 | * @param string|int|float|null|false $ts Timestamp to store. |
242 | * If any falsy value is provided, the timestamp uses the current time instead. |
243 | * @throws TimestampException |
244 | */ |
245 | public function setTimestamp( $ts = false ) { |
246 | $format = null; |
247 | $strtime = ''; |
248 | |
249 | // We want to catch 0, '', null... but not date strings starting with a letter. |
250 | if ( !$ts || $ts === "\0\0\0\0\0\0\0\0\0\0\0\0\0\0" ) { |
251 | $strtime = (string)self::time(); |
252 | $format = 'U'; |
253 | } else { |
254 | foreach ( self::$regexes as $name => $regex ) { |
255 | if ( !preg_match( $regex, $ts, $m ) ) { |
256 | continue; |
257 | } |
258 | |
259 | // Apply RFC 2626 § 11.2 rules for fixing a 2-digit year. |
260 | // We apply by year as written, without regard for |
261 | // offset within the year or timezone of the input date. |
262 | if ( isset( $m['y'] ) ) { |
263 | $pivot = (int)gmdate( 'Y', static::time() ) + 50; |
264 | $m['Y'] = $pivot - ( $pivot % 100 ) + (int)$m['y']; |
265 | if ( $m['Y'] > $pivot ) { |
266 | $m['Y'] -= 100; |
267 | } |
268 | unset( $m['y'] ); |
269 | } |
270 | |
271 | // TS_POSTGRES's match for 'O' can begin with a space, which PHP doesn't accept |
272 | if ( $name === 'TS_POSTGRES' && isset( $m['O'] ) && $m['O'][0] === ' ' ) { |
273 | $m['O'][0] = '+'; |
274 | } |
275 | |
276 | if ( $name === 'TS_RFC2822' ) { |
277 | // RFC 2822 rules for two- and three-digit years |
278 | if ( $m['Y'] < 1000 ) { |
279 | $m['Y'] += $m['Y'] < 50 ? 2000 : 1900; |
280 | } |
281 | |
282 | // TS_RFC2822 timezone fixups |
283 | if ( isset( $m['O'] ) ) { |
284 | // obs-zone value not recognized by PHP |
285 | if ( $m['O'] === 'UT' ) { |
286 | $m['O'] = 'UTC'; |
287 | } |
288 | |
289 | // RFC 2822 says all these should be treated as +0000 due to an error in RFC 822 |
290 | if ( strlen( $m['O'] ) === 1 ) { |
291 | $m['O'] = '+0000'; |
292 | } |
293 | } |
294 | } |
295 | |
296 | if ( $name === 'TS_UNIX_MICRO' && $m['U'] < 0 && $m['u'] > 0 ) { |
297 | // createFromFormat()'s componentwise behavior is counterintuitive in this case, "-1.2" gets |
298 | // interpreted as "-1 seconds + 200000 microseconds = -0.8 seconds" rather than as a decimal |
299 | // "-1.2 seconds" like we want. So correct the values to match the componentwise |
300 | // interpretation. |
301 | $m['U']--; |
302 | $m['u'] = 1000000 - (int)str_pad( $m['u'], 6, '0' ); |
303 | } |
304 | |
305 | $filtered = []; |
306 | foreach ( $m as $k => $v ) { |
307 | if ( !is_int( $k ) && $v !== '' ) { |
308 | $filtered[$k] = $v; |
309 | } |
310 | } |
311 | $format = implode( ' ', array_keys( $filtered ) ); |
312 | $strtime = implode( ' ', array_values( $filtered ) ); |
313 | |
314 | break; |
315 | } |
316 | } |
317 | |
318 | if ( $format === null ) { |
319 | throw new TimestampException( __METHOD__ . ": Invalid timestamp - $ts" ); |
320 | } |
321 | |
322 | try { |
323 | $final = DateTime::createFromFormat( "!$format", $strtime, new DateTimeZone( 'UTC' ) ); |
324 | } catch ( ValueError $e ) { |
325 | throw new TimestampException( __METHOD__ . ': Invalid timestamp format.', $e->getCode(), $e ); |
326 | } |
327 | |
328 | if ( $final === false ) { |
329 | throw new TimestampException( __METHOD__ . ': Invalid timestamp format.' ); |
330 | } |
331 | |
332 | $this->timestamp = $final; |
333 | } |
334 | |
335 | /** |
336 | * Converts any timestamp to the given string format. |
337 | * This is identical to `( new ConvertibleTimestamp() )->getTimestamp()`, |
338 | * except it returns false instead of throwing an exception. |
339 | * |
340 | * @param int $style Constant Output format for timestamp |
341 | * @param string|int|float|null|false|DateTime $ts Timestamp |
342 | * @return string|false Formatted timestamp or false on failure |
343 | */ |
344 | public static function convert( $style, $ts ) { |
345 | try { |
346 | $ct = new static( $ts ); |
347 | return $ct->getTimestamp( $style ); |
348 | } catch ( TimestampException $e ) { |
349 | return false; |
350 | } |
351 | } |
352 | |
353 | /** |
354 | * Get the current time in the given format |
355 | * |
356 | * @param int $style Constant Output format for timestamp |
357 | * @return string |
358 | */ |
359 | public static function now( $style = TS_MW ) { |
360 | return static::convert( $style, false ); |
361 | } |
362 | |
363 | /** |
364 | * Get the timestamp represented by this object in a certain form. |
365 | * |
366 | * Convert the internal timestamp to the specified format and then |
367 | * return it. |
368 | * |
369 | * @param int $style Constant Output format for timestamp |
370 | * @throws TimestampException |
371 | * @return string The formatted timestamp |
372 | */ |
373 | public function getTimestamp( $style = TS_UNIX ) { |
374 | if ( !isset( self::$formats[$style] ) ) { |
375 | throw new TimestampException( __METHOD__ . ': Illegal timestamp output type.' ); |
376 | } |
377 | |
378 | // All our formats are in UTC, so make sure to use that timezone |
379 | $timestamp = clone $this->timestamp; |
380 | $timestamp->setTimezone( new DateTimeZone( 'UTC' ) ); |
381 | |
382 | if ( $style === TS_UNIX_MICRO ) { |
383 | $seconds = (int)$timestamp->format( 'U' ); |
384 | $microseconds = (int)$timestamp->format( 'u' ); |
385 | if ( $seconds < 0 && $microseconds > 0 ) { |
386 | // Adjust components to properly create a decimal number for TS_UNIX_MICRO and negative |
387 | // timestamps. See the comment in setTimestamp() for details. |
388 | $seconds++; |
389 | $microseconds = 1000000 - $microseconds; |
390 | } |
391 | return sprintf( "%d.%06d", $seconds, $microseconds ); |
392 | } |
393 | |
394 | $output = $timestamp->format( self::$formats[$style] ); |
395 | |
396 | if ( $style == TS_RFC2822 ) { |
397 | $output .= ' GMT'; |
398 | } |
399 | |
400 | if ( $style == TS_MW && strlen( $output ) !== 14 ) { |
401 | throw new TimestampException( __METHOD__ . ': The timestamp cannot be represented in ' . |
402 | 'the specified format' ); |
403 | } |
404 | |
405 | return $output; |
406 | } |
407 | |
408 | /** |
409 | * @return string |
410 | * @throws TimestampException |
411 | */ |
412 | public function __toString() { |
413 | return $this->getTimestamp(); |
414 | } |
415 | |
416 | /** |
417 | * Calculate the difference between two ConvertibleTimestamp objects. |
418 | * |
419 | * @param ConvertibleTimestamp $relativeTo Base time to calculate difference from |
420 | * @return DateInterval|false The DateInterval object representing the |
421 | * difference between the two dates or false on failure |
422 | */ |
423 | public function diff( ConvertibleTimestamp $relativeTo ) { |
424 | return $this->timestamp->diff( $relativeTo->timestamp ); |
425 | } |
426 | |
427 | /** |
428 | * Add an interval to the timestamp. |
429 | * @param DateInterval|string $interval DateInterval or DateInterval specification (such as "P2D") |
430 | * @return $this |
431 | * @throws TimestampException |
432 | */ |
433 | public function add( $interval ) { |
434 | if ( is_string( $interval ) ) { |
435 | try { |
436 | $interval = new DateInterval( $interval ); |
437 | } catch ( Exception $e ) { |
438 | throw new TimestampException( __METHOD__ . ': Invalid interval.', $e->getCode(), $e ); |
439 | } |
440 | } |
441 | $this->timestamp->add( $interval ); |
442 | return $this; |
443 | } |
444 | |
445 | /** |
446 | * Subtract an interval from the timestamp. |
447 | * @param DateInterval|string $interval DateInterval or DateInterval specification (such as "P2D") |
448 | * @return $this |
449 | * @throws TimestampException |
450 | */ |
451 | public function sub( $interval ) { |
452 | if ( is_string( $interval ) ) { |
453 | try { |
454 | $interval = new DateInterval( $interval ); |
455 | } catch ( Exception $e ) { |
456 | throw new TimestampException( __METHOD__ . ': Invalid interval.', $e->getCode(), $e ); |
457 | } |
458 | } |
459 | $this->timestamp->sub( $interval ); |
460 | return $this; |
461 | } |
462 | |
463 | /** |
464 | * Set the timezone of this timestamp to the specified timezone. |
465 | * |
466 | * @param string $timezone Timezone to set |
467 | * @throws TimestampException |
468 | */ |
469 | public function setTimezone( $timezone ) { |
470 | try { |
471 | $this->timestamp->setTimezone( new DateTimeZone( $timezone ) ); |
472 | } catch ( Exception $e ) { |
473 | throw new TimestampException( __METHOD__ . ': Invalid timezone.', $e->getCode(), $e ); |
474 | } |
475 | } |
476 | |
477 | /** |
478 | * Get the timezone of this timestamp. |
479 | * |
480 | * @return DateTimeZone The timezone |
481 | */ |
482 | public function getTimezone() { |
483 | return $this->timestamp->getTimezone(); |
484 | } |
485 | |
486 | /** |
487 | * Format the timestamp in a given format. |
488 | * |
489 | * @param string $format Pattern to format in |
490 | * @return string The formatted timestamp |
491 | */ |
492 | public function format( $format ) { |
493 | return $this->timestamp->format( $format ); |
494 | } |
495 | } |