Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
94.17% covered (success)
94.17%
113 / 120
75.00% covered (warning)
75.00%
12 / 16
CRAP
0.00% covered (danger)
0.00%
0 / 1
ConvertibleTimestamp
94.17% covered (success)
94.17%
113 / 120
75.00% covered (warning)
75.00%
12 / 16
64.81
0.00% covered (danger)
0.00%
0 / 1
 time
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 microtime
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
3
 hrtime
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 setFakeTime
93.75% covered (success)
93.75%
15 / 16
0.00% covered (danger)
0.00%
0 / 1
7.01
 __construct
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 setTimestamp
95.24% covered (success)
95.24%
40 / 42
0.00% covered (danger)
0.00%
0 / 1
25
 convert
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 now
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getTimestamp
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
8
 __toString
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 diff
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 add
66.67% covered (warning)
66.67%
4 / 6
0.00% covered (danger)
0.00%
0 / 1
3.33
 sub
66.67% covered (warning)
66.67%
4 / 6
0.00% covered (danger)
0.00%
0 / 1
3.33
 setTimezone
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 getTimezone
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 format
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
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
26namespace Wikimedia\Timestamp;
27
28use DateInterval;
29use DateTime;
30use DateTimeZone;
31use Exception;
32use InvalidArgumentException;
33use ValueError;
34
35/**
36 * Library for creating, parsing, and converting timestamps.
37 */
38class 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     *
134     * This is equivalent to calling PHP's built-in `microtime(true)`.
135     * The exact precision depends on the underlying operating system.
136     *
137     * You can overwrite microtime for testing purposes by calling setFakeTime().
138     * In that case, this will re-use the fake time() value in seconds, and add a
139     * fake microsecond fraction based on an increasing counter.
140     *
141     * Repeated calls to microtime() are likely to return unique and increasing values.
142     * But, there is no guarantee of this, due to clock skew and clock drift correction.
143     * To measure time spent between two points in the code, use the ::hrtime() instead.
144     *
145     * @deprecated To measure relative duration, use ::hrtime(). For timestamps, use ::time().
146     * @return float Seconds since the epoch
147     */
148    public static function microtime(): float {
149        trigger_error( __METHOD__ . ' use ::hrtime() instead.', E_USER_DEPRECATED );
150
151        static $fakeSecond = 0;
152        static $fakeOffset = 0.0;
153        if ( static::$fakeTimeCallback ) {
154            $sec = static::time();
155            if ( $sec !== $fakeSecond ) {
156                $fakeSecond = $sec;
157                $fakeOffset = 0.0;
158            } else {
159                $fakeOffset++;
160            }
161            return $fakeSecond + $fakeOffset * 0.000001;
162        } else {
163            return microtime( true );
164        }
165    }
166
167    /**
168     * Get the value of a monotonic clock in nanoseconds.
169     *
170     * This is equivalent to calling PHP's built-in `hrtime(true)`. Repeated calls to
171     * hrtime() are guruanteed to be equal to or higher than the previous call. It is
172     * designed to measure time between two calls, and is not affected by clock
173     * drift correction, and thus does not represent any kind of date or timestamp.
174     *
175     * The output of this method can be controlled for testing purposes by calling
176     * setFakeTime() with any value. In that case, hrtime() will always increment
177     * its arbitrary starting amount by 1 millisecond every time the function is
178     * called.
179     *
180     * @see https://www.php.net/hrtime
181     * @return int|float Monotonic nanoseconds since an arbitrary starting point
182     */
183    public static function hrtime() {
184        static $fakeNanoMono = 41_999_000_000;
185
186        if ( static::$fakeTimeCallback ) {
187            // Use a fake start. We ignore the actual fake time because:
188            // 1. Using it would make it easy to mistakenly use it as a timestamp.
189            // 2. Multiplying time() by 1e9 for nanoseconds since 1970 is larger than
190            //    fits in float precision, thus causing all kinds of accuracy problems.
191            //
192            // We add 1 millisecond each time this method is called, so that it is always
193            // increasing, and ensures `b - a` gives a deterministic end result that can
194            // be strictly asserted in a unit test.
195            $fakeNanoMono += 1_000_000;
196            return $fakeNanoMono;
197        } else {
198            return hrtime( true );
199        }
200    }
201
202    /**
203     * Set a fake time value or clock callback.
204     *
205     * @param callable|ConvertibleTimestamp|string|int|false $fakeTime a fixed time given as a string,
206     *   or as a number representing seconds since the UNIX epoch; or a callback that returns an int.
207     *   or false to disable fake time and go back to real time.
208     * @param int|float $step The number of seconds by which to increment the clock each time
209     *   the time() method is called. Must not be smaller than zero.
210     *   Ignored if $fakeTime is a callback or false.
211     *
212     * @return callable|null the previous fake time callback, if any.
213     *
214     * @phan-param callable():int|ConvertibleTimestamp|string|int|false $fakeTime
215     */
216    public static function setFakeTime( $fakeTime, $step = 0 ) {
217        if ( $fakeTime instanceof ConvertibleTimestamp ) {
218            $fakeTime = (int)$fakeTime->getTimestamp();
219        }
220
221        if ( is_string( $fakeTime ) ) {
222            $fakeTime = (int)static::convert( TS_UNIX, $fakeTime );
223        }
224
225        if ( is_int( $fakeTime ) ) {
226            $clock = $fakeTime;
227            $fakeTime = static function () use ( &$clock, $step ) {
228                $t = $clock;
229                $clock += $step;
230                return (int)$t;
231            };
232        }
233
234        if ( $fakeTime && !is_callable( $fakeTime ) ) {
235            throw new InvalidArgumentException( 'Bad fake time' );
236        }
237
238        $old = static::$fakeTimeCallback;
239        static::$fakeTimeCallback = $fakeTime ?: null;
240        return $old;
241    }
242
243    /**
244     * The actual timestamp being wrapped (DateTime object).
245     * @var DateTime
246     */
247    public $timestamp;
248
249    /**
250     * Make a new timestamp and set it to the specified time,
251     * or the current time if unspecified.
252     *
253     * @param string|int|float|null|false|DateTime $timestamp Timestamp to set.
254     *   If any falsy value is provided, the timestamp uses the current time instead.
255     * @throws TimestampException
256     */
257    public function __construct( $timestamp = false ) {
258        if ( $timestamp instanceof DateTime ) {
259            $this->timestamp = $timestamp;
260        } else {
261            $this->setTimestamp( $timestamp );
262        }
263    }
264
265    /**
266     * Set the timestamp to the specified time, or the current time if unspecified.
267     *
268     * Parse the given timestamp into either a DateTime object or a Unix timestamp,
269     * and then store it.
270     *
271     * @param string|int|float|null|false $ts Timestamp to store.
272     *   If any falsy value is provided, the timestamp uses the current time instead.
273     * @throws TimestampException
274     */
275    public function setTimestamp( $ts = false ) {
276        $format = null;
277        $strtime = '';
278
279        // We want to catch 0, '', null... but not date strings starting with a letter.
280        if ( !$ts || $ts === "\0\0\0\0\0\0\0\0\0\0\0\0\0\0" ) {
281            $strtime = (string)self::time();
282            $format = 'U';
283        } else {
284            foreach ( self::$regexes as $name => $regex ) {
285                if ( !preg_match( $regex, $ts, $m ) ) {
286                    continue;
287                }
288
289                // Apply RFC 2626 Â§ 11.2 rules for fixing a 2-digit year.
290                // We apply by year as written, without regard for
291                // offset within the year or timezone of the input date.
292                if ( isset( $m['y'] ) ) {
293                    $pivot = (int)gmdate( 'Y', static::time() ) + 50;
294                    $m['Y'] = $pivot - ( $pivot % 100 ) + (int)$m['y'];
295                    if ( $m['Y'] > $pivot ) {
296                        $m['Y'] -= 100;
297                    }
298                    unset( $m['y'] );
299                }
300
301                // TS_POSTGRES's match for 'O' can begin with a space, which PHP doesn't accept
302                if ( $name === 'TS_POSTGRES' && isset( $m['O'] ) && $m['O'][0] === ' ' ) {
303                    $m['O'][0] = '+';
304                }
305
306                if ( $name === 'TS_RFC2822' ) {
307                    // RFC 2822 rules for two- and three-digit years
308                    if ( $m['Y'] < 1000 ) {
309                        $m['Y'] += $m['Y'] < 50 ? 2000 : 1900;
310                    }
311
312                    // TS_RFC2822 timezone fixups
313                    if ( isset( $m['O'] ) ) {
314                        // obs-zone value not recognized by PHP
315                        if ( $m['O'] === 'UT' ) {
316                            $m['O'] = 'UTC';
317                        }
318
319                        // RFC 2822 says all these should be treated as +0000 due to an error in RFC 822
320                        if ( strlen( $m['O'] ) === 1 ) {
321                            $m['O'] = '+0000';
322                        }
323                    }
324                }
325
326                if ( $name === 'TS_UNIX_MICRO' && $m['U'] < 0 && $m['u'] > 0 ) {
327                    // createFromFormat()'s componentwise behavior is counterintuitive in this case, "-1.2" gets
328                    // interpreted as "-1 seconds + 200000 microseconds = -0.8 seconds" rather than as a decimal
329                    // "-1.2 seconds" like we want. So correct the values to match the componentwise
330                    // interpretation.
331                    $m['U']--;
332                    $m['u'] = 1000000 - (int)str_pad( $m['u'], 6, '0' );
333                }
334
335                $filtered = [];
336                foreach ( $m as $k => $v ) {
337                    if ( !is_int( $k ) && $v !== '' ) {
338                        $filtered[$k] = $v;
339                    }
340                }
341                $format = implode( ' ', array_keys( $filtered ) );
342                $strtime = implode( ' ', array_values( $filtered ) );
343
344                break;
345            }
346        }
347
348        if ( $format === null ) {
349            throw new TimestampException( __METHOD__ . ": Invalid timestamp - $ts" );
350        }
351
352        try {
353            $final = DateTime::createFromFormat( "!$format", $strtime, new DateTimeZone( 'UTC' ) );
354        } catch ( ValueError $e ) {
355            throw new TimestampException( __METHOD__ . ': Invalid timestamp format.', $e->getCode(), $e );
356        }
357
358        if ( $final === false ) {
359            throw new TimestampException( __METHOD__ . ': Invalid timestamp format.' );
360        }
361
362        $this->timestamp = $final;
363    }
364
365    /**
366     * Converts any timestamp to the given string format.
367     * This is identical to `( new ConvertibleTimestamp() )->getTimestamp()`,
368     * except it returns false instead of throwing an exception.
369     *
370     * @param int $style Constant Output format for timestamp
371     * @param string|int|float|null|false|DateTime $ts Timestamp
372     * @return string|false Formatted timestamp or false on failure
373     */
374    public static function convert( $style, $ts ) {
375        try {
376            $ct = new static( $ts );
377            return $ct->getTimestamp( $style );
378        } catch ( TimestampException $e ) {
379            return false;
380        }
381    }
382
383    /**
384     * Get the current time in the given format
385     *
386     * @param int $style Constant Output format for timestamp
387     * @return string
388     */
389    public static function now( $style = TS_MW ) {
390        return static::convert( $style, false );
391    }
392
393    /**
394     * Get the timestamp represented by this object in a certain form.
395     *
396     * Convert the internal timestamp to the specified format and then
397     * return it.
398     *
399     * @param int $style Constant Output format for timestamp
400     * @throws TimestampException
401     * @return string The formatted timestamp
402     */
403    public function getTimestamp( $style = TS_UNIX ) {
404        if ( !isset( self::$formats[$style] ) ) {
405            throw new TimestampException( __METHOD__ . ': Illegal timestamp output type.' );
406        }
407
408        // All our formats are in UTC, so make sure to use that timezone
409        $timestamp = clone $this->timestamp;
410        $timestamp->setTimezone( new DateTimeZone( 'UTC' ) );
411
412        if ( $style === TS_UNIX_MICRO ) {
413            $seconds = (int)$timestamp->format( 'U' );
414            $microseconds = (int)$timestamp->format( 'u' );
415            if ( $seconds < 0 && $microseconds > 0 ) {
416                // Adjust components to properly create a decimal number for TS_UNIX_MICRO and negative
417                // timestamps. See the comment in setTimestamp() for details.
418                $seconds++;
419                $microseconds = 1000000 - $microseconds;
420            }
421            return sprintf( "%d.%06d", $seconds, $microseconds );
422        }
423
424        $output = $timestamp->format( self::$formats[$style] );
425
426        if ( $style == TS_RFC2822 ) {
427            $output .= ' GMT';
428        }
429
430        if ( $style == TS_MW && strlen( $output ) !== 14 ) {
431            throw new TimestampException( __METHOD__ . ': The timestamp cannot be represented in ' .
432                'the specified format' );
433        }
434
435        return $output;
436    }
437
438    /**
439     * @return string
440     * @throws TimestampException
441     */
442    public function __toString() {
443        return $this->getTimestamp();
444    }
445
446    /**
447     * Calculate the difference between two ConvertibleTimestamp objects.
448     *
449     * @param ConvertibleTimestamp $relativeTo Base time to calculate difference from
450     * @return DateInterval|false The DateInterval object representing the
451     *   difference between the two dates or false on failure
452     */
453    public function diff( ConvertibleTimestamp $relativeTo ) {
454        return $this->timestamp->diff( $relativeTo->timestamp );
455    }
456
457    /**
458     * Add an interval to the timestamp.
459     * @param DateInterval|string $interval DateInterval or DateInterval specification (such as "P2D")
460     * @return $this
461     * @throws TimestampException
462     */
463    public function add( $interval ) {
464        if ( is_string( $interval ) ) {
465            try {
466                $interval = new DateInterval( $interval );
467            } catch ( Exception $e ) {
468                throw new TimestampException( __METHOD__ . ': Invalid interval.', $e->getCode(), $e );
469            }
470        }
471        $this->timestamp->add( $interval );
472        return $this;
473    }
474
475    /**
476     * Subtract an interval from the timestamp.
477     * @param DateInterval|string $interval DateInterval or DateInterval specification (such as "P2D")
478     * @return $this
479     * @throws TimestampException
480     */
481    public function sub( $interval ) {
482        if ( is_string( $interval ) ) {
483            try {
484                $interval = new DateInterval( $interval );
485            } catch ( Exception $e ) {
486                throw new TimestampException( __METHOD__ . ': Invalid interval.', $e->getCode(), $e );
487            }
488        }
489        $this->timestamp->sub( $interval );
490        return $this;
491    }
492
493    /**
494     * Set the timezone of this timestamp to the specified timezone.
495     *
496     * @param string $timezone Timezone to set
497     * @throws TimestampException
498     */
499    public function setTimezone( $timezone ) {
500        try {
501            $this->timestamp->setTimezone( new DateTimeZone( $timezone ) );
502        } catch ( Exception $e ) {
503            throw new TimestampException( __METHOD__ . ': Invalid timezone.', $e->getCode(), $e );
504        }
505    }
506
507    /**
508     * Get the timezone of this timestamp.
509     *
510     * @return DateTimeZone The timezone
511     */
512    public function getTimezone() {
513        return $this->timestamp->getTimezone();
514    }
515
516    /**
517     * Format the timestamp in a given format.
518     *
519     * @param string $format Pattern to format in
520     * @return string The formatted timestamp
521     */
522    public function format( $format ) {
523        return $this->timestamp->format( $format );
524    }
525}