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;
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     * 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}