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