Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
93.86% covered (success)
93.86%
107 / 114
73.33% covered (warning)
73.33%
11 / 15
CRAP
0.00% covered (danger)
0.00%
0 / 1
ConvertibleTimestamp
93.86% covered (success)
93.86%
107 / 114
73.33% covered (warning)
73.33%
11 / 15
62.89
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%
10 / 10
100.00% covered (success)
100.00%
1 / 1
3
 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;
33
34/**
35 * Library for creating, parsing, and converting timestamps.
36 */
37class 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}