PageRenderTime 48ms CodeModel.GetById 15ms RepoModel.GetById 0ms app.codeStats 1ms

/core/lib/Drupal/Component/Datetime/DateTimePlus.php

http://github.com/drupal/drupal
PHP | 727 lines | 260 code | 57 blank | 410 comment | 47 complexity | 2cac4d9ef521afcc5fe0c5d58e0bf0ff MD5 | raw file
Possible License(s): GPL-2.0, LGPL-2.1
  1. <?php
  2. namespace Drupal\Component\Datetime;
  3. use Drupal\Component\Utility\ToStringTrait;
  4. /**
  5. * Wraps DateTime().
  6. *
  7. * This class wraps the PHP DateTime class with more flexible initialization
  8. * parameters, allowing a date to be created from an existing date object,
  9. * a timestamp, a string with an unknown format, a string with a known
  10. * format, or an array of date parts. It also adds an errors array
  11. * and a __toString() method to the date object.
  12. *
  13. * This class is less lenient than the DateTime class. It changes
  14. * the default behavior for handling date values like '2011-00-00'.
  15. * The DateTime class would convert that value to '2010-11-30' and report
  16. * a warning but not an error. This extension treats that as an error.
  17. *
  18. * As with the DateTime class, a date object may be created even if it has
  19. * errors. It has an errors array attached to it that explains what the
  20. * errors are. This is less disruptive than allowing datetime exceptions
  21. * to abort processing. The calling script can decide what to do about
  22. * errors using hasErrors() and getErrors().
  23. *
  24. * @method $this add(\DateInterval $interval)
  25. * @method static array getLastErrors()
  26. * @method $this modify(string $modify)
  27. * @method $this setDate(int $year, int $month, int $day)
  28. * @method $this setISODate(int $year, int $week, int $day = 1)
  29. * @method $this setTime(int $hour, int $minute, int $second = 0, int $microseconds = 0)
  30. * @method $this setTimestamp(int $unixtimestamp)
  31. * @method $this setTimezone(\DateTimeZone $timezone)
  32. * @method $this sub(\DateInterval $interval)
  33. * @method int getOffset()
  34. * @method int getTimestamp()
  35. * @method \DateTimeZone getTimezone()
  36. */
  37. class DateTimePlus {
  38. use ToStringTrait;
  39. const FORMAT = 'Y-m-d H:i:s';
  40. /**
  41. * A RFC7231 Compliant date.
  42. *
  43. * @see http://tools.ietf.org/html/rfc7231#section-7.1.1.1
  44. *
  45. * Example: Sun, 06 Nov 1994 08:49:37 GMT
  46. */
  47. const RFC7231 = 'D, d M Y H:i:s \G\M\T';
  48. /**
  49. * An array of possible date parts.
  50. */
  51. protected static $dateParts = [
  52. 'year',
  53. 'month',
  54. 'day',
  55. 'hour',
  56. 'minute',
  57. 'second',
  58. ];
  59. /**
  60. * The value of the time value passed to the constructor.
  61. *
  62. * @var string
  63. */
  64. protected $inputTimeRaw = '';
  65. /**
  66. * The prepared time, without timezone, for this date.
  67. *
  68. * @var string
  69. */
  70. protected $inputTimeAdjusted = '';
  71. /**
  72. * The value of the timezone passed to the constructor.
  73. *
  74. * @var string
  75. */
  76. protected $inputTimeZoneRaw = '';
  77. /**
  78. * The prepared timezone object used to construct this date.
  79. *
  80. * @var string
  81. */
  82. protected $inputTimeZoneAdjusted = '';
  83. /**
  84. * The value of the format passed to the constructor.
  85. *
  86. * @var string
  87. */
  88. protected $inputFormatRaw = '';
  89. /**
  90. * The prepared format, if provided.
  91. *
  92. * @var string
  93. */
  94. protected $inputFormatAdjusted = '';
  95. /**
  96. * The value of the language code passed to the constructor.
  97. */
  98. protected $langcode = NULL;
  99. /**
  100. * An array of errors encountered when creating this date.
  101. */
  102. protected $errors = [];
  103. /**
  104. * The DateTime object.
  105. *
  106. * @var \DateTime
  107. */
  108. protected $dateTimeObject = NULL;
  109. /**
  110. * Creates a date object from an input date object.
  111. *
  112. * @param \DateTime $datetime
  113. * A DateTime object.
  114. * @param array $settings
  115. * @see __construct()
  116. *
  117. * @return static
  118. * A new DateTimePlus object.
  119. */
  120. public static function createFromDateTime(\DateTime $datetime, $settings = []) {
  121. return new static($datetime->format(static::FORMAT), $datetime->getTimezone(), $settings);
  122. }
  123. /**
  124. * Creates a date object from an array of date parts.
  125. *
  126. * Converts the input value into an ISO date, forcing a full ISO
  127. * date even if some values are missing.
  128. *
  129. * @param array $date_parts
  130. * An array of date parts, like ('year' => 2014, 'month' => 4).
  131. * @param mixed $timezone
  132. * (optional) \DateTimeZone object, time zone string or NULL. NULL uses the
  133. * default system time zone. Defaults to NULL.
  134. * @param array $settings
  135. * (optional) A keyed array for settings, suitable for passing on to
  136. * __construct().
  137. *
  138. * @return static
  139. * A new DateTimePlus object.
  140. *
  141. * @throws \InvalidArgumentException
  142. * If the array date values or value combination is not correct.
  143. */
  144. public static function createFromArray(array $date_parts, $timezone = NULL, $settings = []) {
  145. $date_parts = static::prepareArray($date_parts, TRUE);
  146. if (static::checkArray($date_parts)) {
  147. // Even with validation, we can end up with a value that the
  148. // DateTime class won't handle, like a year outside the range
  149. // of -9999 to 9999, which will pass checkdate() but
  150. // fail to construct a date object.
  151. $iso_date = static::arrayToISO($date_parts);
  152. return new static($iso_date, $timezone, $settings);
  153. }
  154. else {
  155. throw new \InvalidArgumentException('The array contains invalid values.');
  156. }
  157. }
  158. /**
  159. * Creates a date object from timestamp input.
  160. *
  161. * The timezone of a timestamp is always UTC. The timezone for a
  162. * timestamp indicates the timezone used by the format() method.
  163. *
  164. * @param int $timestamp
  165. * A UNIX timestamp.
  166. * @param mixed $timezone
  167. * @see __construct()
  168. * @param array $settings
  169. * @see __construct()
  170. *
  171. * @return static
  172. * A new DateTimePlus object.
  173. *
  174. * @throws \InvalidArgumentException
  175. * If the timestamp is not numeric.
  176. */
  177. public static function createFromTimestamp($timestamp, $timezone = NULL, $settings = []) {
  178. if (!is_numeric($timestamp)) {
  179. throw new \InvalidArgumentException('The timestamp must be numeric.');
  180. }
  181. $datetime = new static('', $timezone, $settings);
  182. $datetime->setTimestamp($timestamp);
  183. return $datetime;
  184. }
  185. /**
  186. * Creates a date object from an input format.
  187. *
  188. * @param string $format
  189. * PHP date() type format for parsing the input. This is recommended
  190. * to use things like negative years, which php's parser fails on, or
  191. * any other specialized input with a known format. If provided the
  192. * date will be created using the createFromFormat() method.
  193. * @see http://php.net/manual/datetime.createfromformat.php
  194. * @param mixed $time
  195. * @see __construct()
  196. * @param mixed $timezone
  197. * @see __construct()
  198. * @param array $settings
  199. * - validate_format: (optional) Boolean choice to validate the
  200. * created date using the input format. The format used in
  201. * createFromFormat() allows slightly different values than format().
  202. * Using an input format that works in both functions makes it
  203. * possible to a validation step to confirm that the date created
  204. * from a format string exactly matches the input. This option
  205. * indicates the format can be used for validation. Defaults to TRUE.
  206. * @see __construct()
  207. *
  208. * @return static
  209. * A new DateTimePlus object.
  210. *
  211. * @throws \InvalidArgumentException
  212. * If the a date cannot be created from the given format.
  213. * @throws \UnexpectedValueException
  214. * If the created date does not match the input value.
  215. */
  216. public static function createFromFormat($format, $time, $timezone = NULL, $settings = []) {
  217. if (!isset($settings['validate_format'])) {
  218. $settings['validate_format'] = TRUE;
  219. }
  220. // Tries to create a date from the format and use it if possible.
  221. // A regular try/catch won't work right here, if the value is
  222. // invalid it doesn't return an exception.
  223. $datetimeplus = new static('', $timezone, $settings);
  224. $date = \DateTime::createFromFormat($format, $time, $datetimeplus->getTimezone());
  225. if (!$date instanceof \DateTime) {
  226. throw new \InvalidArgumentException('The date cannot be created from a format.');
  227. }
  228. else {
  229. // Functions that parse date is forgiving, it might create a date that
  230. // is not exactly a match for the provided value, so test for that by
  231. // re-creating the date/time formatted string and comparing it to the input. For
  232. // instance, an input value of '11' using a format of Y (4 digits) gets
  233. // created as '0011' instead of '2011'.
  234. if ($date instanceof DateTimePlus) {
  235. $test_time = $date->format($format, $settings);
  236. }
  237. elseif ($date instanceof \DateTime) {
  238. $test_time = $date->format($format);
  239. }
  240. $datetimeplus->setTimestamp($date->getTimestamp());
  241. $datetimeplus->setTimezone($date->getTimezone());
  242. if ($settings['validate_format'] && $test_time != $time) {
  243. throw new \UnexpectedValueException('The created date does not match the input value.');
  244. }
  245. }
  246. return $datetimeplus;
  247. }
  248. /**
  249. * Constructs a date object set to a requested date and timezone.
  250. *
  251. * @param string $time
  252. * (optional) A date/time string. Defaults to 'now'.
  253. * @param mixed $timezone
  254. * (optional) \DateTimeZone object, time zone string or NULL. NULL uses the
  255. * default system time zone. Defaults to NULL. Note that the $timezone
  256. * parameter and the current timezone are ignored when the $time parameter
  257. * either is a UNIX timestamp (e.g. @946684800) or specifies a timezone
  258. * (e.g. 2010-01-28T15:00:00+02:00).
  259. * @see http://php.net/manual/datetime.construct.php
  260. * @param array $settings
  261. * (optional) Keyed array of settings. Defaults to empty array.
  262. * - langcode: (optional) String two letter language code used to control
  263. * the result of the format(). Defaults to NULL.
  264. * - debug: (optional) Boolean choice to leave debug values in the
  265. * date object for debugging purposes. Defaults to FALSE.
  266. */
  267. public function __construct($time = 'now', $timezone = NULL, $settings = []) {
  268. // Unpack settings.
  269. $this->langcode = !empty($settings['langcode']) ? $settings['langcode'] : NULL;
  270. // Massage the input values as necessary.
  271. $prepared_time = $this->prepareTime($time);
  272. $prepared_timezone = $this->prepareTimezone($timezone);
  273. try {
  274. $this->errors = [];
  275. if (!empty($prepared_time)) {
  276. $test = date_parse($prepared_time);
  277. if (!empty($test['errors'])) {
  278. $this->errors = $test['errors'];
  279. }
  280. }
  281. if (empty($this->errors)) {
  282. $this->dateTimeObject = new \DateTime($prepared_time, $prepared_timezone);
  283. }
  284. }
  285. catch (\Exception $e) {
  286. $this->errors[] = $e->getMessage();
  287. }
  288. // Clean up the error messages.
  289. $this->checkErrors();
  290. }
  291. /**
  292. * Renders the timezone name.
  293. *
  294. * @return string
  295. */
  296. public function render() {
  297. return $this->format(static::FORMAT) . ' ' . $this->getTimeZone()->getName();
  298. }
  299. /**
  300. * Implements the magic __call method.
  301. *
  302. * Passes through all unknown calls onto the DateTime object.
  303. *
  304. * @param string $method
  305. * The method to call on the decorated object.
  306. * @param array $args
  307. * Call arguments.
  308. *
  309. * @return mixed
  310. * The return value from the method on the decorated object. If the proxied
  311. * method call returns a DateTime object, then return the original
  312. * DateTimePlus object, which allows function chaining to work properly.
  313. * Otherwise, the value from the proxied method call is returned.
  314. *
  315. * @throws \Exception
  316. * Thrown when the DateTime object is not set.
  317. * @throws \BadMethodCallException
  318. * Thrown when there is no corresponding method on the DateTime object to
  319. * call.
  320. */
  321. public function __call($method, array $args) {
  322. // @todo consider using assert() as per https://www.drupal.org/node/2451793.
  323. if (!isset($this->dateTimeObject)) {
  324. throw new \Exception('DateTime object not set.');
  325. }
  326. if (!method_exists($this->dateTimeObject, $method)) {
  327. throw new \BadMethodCallException(sprintf('Call to undefined method %s::%s()', get_class($this), $method));
  328. }
  329. $result = call_user_func_array([$this->dateTimeObject, $method], $args);
  330. return $result === $this->dateTimeObject ? $this : $result;
  331. }
  332. /**
  333. * Returns the difference between two DateTimePlus objects.
  334. *
  335. * @param \Drupal\Component\Datetime\DateTimePlus|\DateTime $datetime2
  336. * The date to compare to.
  337. * @param bool $absolute
  338. * Should the interval be forced to be positive?
  339. *
  340. * @return \DateInterval
  341. * A DateInterval object representing the difference between the two dates.
  342. *
  343. * @throws \BadMethodCallException
  344. * If the input isn't a DateTime or DateTimePlus object.
  345. */
  346. public function diff($datetime2, $absolute = FALSE) {
  347. if ($datetime2 instanceof DateTimePlus) {
  348. $datetime2 = $datetime2->dateTimeObject;
  349. }
  350. if (!($datetime2 instanceof \DateTime)) {
  351. throw new \BadMethodCallException(sprintf('Method %s expects parameter 1 to be a \DateTime or \Drupal\Component\Datetime\DateTimePlus object', __METHOD__));
  352. }
  353. return $this->dateTimeObject->diff($datetime2, $absolute);
  354. }
  355. /**
  356. * Implements the magic __callStatic method.
  357. *
  358. * Passes through all unknown static calls onto the DateTime object.
  359. */
  360. public static function __callStatic($method, $args) {
  361. if (!method_exists('\DateTime', $method)) {
  362. throw new \BadMethodCallException(sprintf('Call to undefined method %s::%s()', get_called_class(), $method));
  363. }
  364. return call_user_func_array(['\DateTime', $method], $args);
  365. }
  366. /**
  367. * Implements the magic __clone method.
  368. *
  369. * Deep-clones the DateTime object we're wrapping.
  370. */
  371. public function __clone() {
  372. $this->dateTimeObject = clone($this->dateTimeObject);
  373. }
  374. /**
  375. * Prepares the input time value.
  376. *
  377. * Changes the input value before trying to use it, if necessary.
  378. * Can be overridden to handle special cases.
  379. *
  380. * @param mixed $time
  381. * An input value, which could be a timestamp, a string,
  382. * or an array of date parts.
  383. *
  384. * @return mixed
  385. * The massaged time.
  386. */
  387. protected function prepareTime($time) {
  388. return $time;
  389. }
  390. /**
  391. * Prepares the input timezone value.
  392. *
  393. * Changes the timezone before trying to use it, if necessary.
  394. * Most importantly, makes sure there is a valid timezone
  395. * object before moving further.
  396. *
  397. * @param mixed $timezone
  398. * Either a timezone name or a timezone object or NULL.
  399. *
  400. * @return \DateTimeZone
  401. * The massaged time zone.
  402. */
  403. protected function prepareTimezone($timezone) {
  404. // If the input timezone is a valid timezone object, use it.
  405. if ($timezone instanceof \DateTimezone) {
  406. $timezone_adjusted = $timezone;
  407. }
  408. // Allow string timezone input, and create a timezone from it.
  409. elseif (!empty($timezone) && is_string($timezone)) {
  410. $timezone_adjusted = new \DateTimeZone($timezone);
  411. }
  412. // Default to the system timezone when not explicitly provided.
  413. // If the system timezone is missing, use 'UTC'.
  414. if (empty($timezone_adjusted) || !$timezone_adjusted instanceof \DateTimezone) {
  415. $system_timezone = date_default_timezone_get();
  416. $timezone_name = !empty($system_timezone) ? $system_timezone : 'UTC';
  417. $timezone_adjusted = new \DateTimeZone($timezone_name);
  418. }
  419. // We are finally certain that we have a usable timezone.
  420. return $timezone_adjusted;
  421. }
  422. /**
  423. * Prepares the input format value.
  424. *
  425. * Changes the input format before trying to use it, if necessary.
  426. * Can be overridden to handle special cases.
  427. *
  428. * @param string $format
  429. * A PHP format string.
  430. *
  431. * @return string
  432. * The massaged PHP format string.
  433. */
  434. protected function prepareFormat($format) {
  435. return $format;
  436. }
  437. /**
  438. * Examines getLastErrors() to see what errors to report.
  439. *
  440. * Two kinds of errors are important: anything that DateTime
  441. * considers an error, and also a warning that the date was invalid.
  442. * PHP creates a valid date from invalid data with only a warning,
  443. * 2011-02-30 becomes 2011-03-03, for instance, but we don't want that.
  444. *
  445. * @see http://php.net/manual/time.getlasterrors.php
  446. */
  447. public function checkErrors() {
  448. $errors = \DateTime::getLastErrors();
  449. if (!empty($errors['errors'])) {
  450. $this->errors = array_merge($this->errors, $errors['errors']);
  451. }
  452. // Most warnings are messages that the date could not be parsed
  453. // which causes it to be altered. For validation purposes, a warning
  454. // as bad as an error, because it means the constructed date does
  455. // not match the input value.
  456. if (!empty($errors['warnings'])) {
  457. $this->errors[] = 'The date is invalid.';
  458. }
  459. $this->errors = array_values(array_unique($this->errors));
  460. }
  461. /**
  462. * Detects if there were errors in the processing of this date.
  463. *
  464. * @return bool
  465. * TRUE if there were errors in the processing of this date, FALSE
  466. * otherwise.
  467. */
  468. public function hasErrors() {
  469. return (boolean) count($this->errors);
  470. }
  471. /**
  472. * Gets error messages.
  473. *
  474. * Public function to return the error messages.
  475. *
  476. * @return array
  477. * An array of errors encountered when creating this date.
  478. */
  479. public function getErrors() {
  480. return $this->errors;
  481. }
  482. /**
  483. * Creates an ISO date from an array of values.
  484. *
  485. * @param array $array
  486. * An array of date values keyed by date part.
  487. * @param bool $force_valid_date
  488. * (optional) Whether to force a full date by filling in missing
  489. * values. Defaults to FALSE.
  490. *
  491. * @return string
  492. * The date as an ISO string.
  493. */
  494. public static function arrayToISO($array, $force_valid_date = FALSE) {
  495. $array = static::prepareArray($array, $force_valid_date);
  496. $input_time = '';
  497. if ($array['year'] !== '') {
  498. $input_time = static::datePad(intval($array['year']), 4);
  499. if ($force_valid_date || $array['month'] !== '') {
  500. $input_time .= '-' . static::datePad(intval($array['month']));
  501. if ($force_valid_date || $array['day'] !== '') {
  502. $input_time .= '-' . static::datePad(intval($array['day']));
  503. }
  504. }
  505. }
  506. if ($array['hour'] !== '') {
  507. $input_time .= $input_time ? 'T' : '';
  508. $input_time .= static::datePad(intval($array['hour']));
  509. if ($force_valid_date || $array['minute'] !== '') {
  510. $input_time .= ':' . static::datePad(intval($array['minute']));
  511. if ($force_valid_date || $array['second'] !== '') {
  512. $input_time .= ':' . static::datePad(intval($array['second']));
  513. }
  514. }
  515. }
  516. return $input_time;
  517. }
  518. /**
  519. * Creates a complete array from a possibly incomplete array of date parts.
  520. *
  521. * @param array $array
  522. * An array of date values keyed by date part.
  523. * @param bool $force_valid_date
  524. * (optional) Whether to force a valid date by filling in missing
  525. * values with valid values or just to use empty values instead.
  526. * Defaults to FALSE.
  527. *
  528. * @return array
  529. * A complete array of date parts.
  530. */
  531. public static function prepareArray($array, $force_valid_date = FALSE) {
  532. if ($force_valid_date) {
  533. $now = new \DateTime();
  534. $array += [
  535. 'year' => $now->format('Y'),
  536. 'month' => 1,
  537. 'day' => 1,
  538. 'hour' => 0,
  539. 'minute' => 0,
  540. 'second' => 0,
  541. ];
  542. }
  543. else {
  544. $array += [
  545. 'year' => '',
  546. 'month' => '',
  547. 'day' => '',
  548. 'hour' => '',
  549. 'minute' => '',
  550. 'second' => '',
  551. ];
  552. }
  553. return $array;
  554. }
  555. /**
  556. * Checks that arrays of date parts will create a valid date.
  557. *
  558. * Checks that an array of date parts has a year, month, and day,
  559. * and that those values create a valid date. If time is provided,
  560. * verifies that the time values are valid. Sort of an
  561. * equivalent to checkdate().
  562. *
  563. * @param array $array
  564. * An array of datetime values keyed by date part.
  565. *
  566. * @return bool
  567. * TRUE if the datetime parts contain valid values, otherwise FALSE.
  568. */
  569. public static function checkArray($array) {
  570. $valid_date = FALSE;
  571. $valid_time = TRUE;
  572. // Check for a valid date using checkdate(). Only values that
  573. // meet that test are valid.
  574. if (array_key_exists('year', $array) && array_key_exists('month', $array) && array_key_exists('day', $array)) {
  575. if (@checkdate($array['month'], $array['day'], $array['year'])) {
  576. $valid_date = TRUE;
  577. }
  578. }
  579. // Testing for valid time is reversed. Missing time is OK,
  580. // but incorrect values are not.
  581. foreach (['hour', 'minute', 'second'] as $key) {
  582. if (array_key_exists($key, $array)) {
  583. $value = $array[$key];
  584. switch ($key) {
  585. case 'hour':
  586. if (!preg_match('/^([1-2][0-3]|[01]?[0-9])$/', $value)) {
  587. $valid_time = FALSE;
  588. }
  589. break;
  590. case 'minute':
  591. case 'second':
  592. default:
  593. if (!preg_match('/^([0-5][0-9]|[0-9])$/', $value)) {
  594. $valid_time = FALSE;
  595. }
  596. break;
  597. }
  598. }
  599. }
  600. return $valid_date && $valid_time;
  601. }
  602. /**
  603. * Pads date parts with zeros.
  604. *
  605. * Helper function for a task that is often required when working with dates.
  606. *
  607. * @param int $value
  608. * The value to pad.
  609. * @param int $size
  610. * (optional) Size expected, usually 2 or 4. Defaults to 2.
  611. *
  612. * @return string
  613. * The padded value.
  614. */
  615. public static function datePad($value, $size = 2) {
  616. return sprintf("%0" . $size . "d", $value);
  617. }
  618. /**
  619. * Formats the date for display.
  620. *
  621. * @param string $format
  622. * Format accepted by date().
  623. * @param array $settings
  624. * - timezone: (optional) String timezone name. Defaults to the timezone
  625. * of the date object.
  626. *
  627. * @return string|null
  628. * The formatted value of the date or NULL if there were construction
  629. * errors.
  630. */
  631. public function format($format, $settings = []) {
  632. // If there were construction errors, we can't format the date.
  633. if ($this->hasErrors()) {
  634. return;
  635. }
  636. // Format the date and catch errors.
  637. try {
  638. // Clone the date/time object so we can change the time zone without
  639. // disturbing the value stored in the object.
  640. $dateTimeObject = clone $this->dateTimeObject;
  641. if (isset($settings['timezone'])) {
  642. $dateTimeObject->setTimezone(new \DateTimeZone($settings['timezone']));
  643. }
  644. $value = $dateTimeObject->format($format);
  645. }
  646. catch (\Exception $e) {
  647. $this->errors[] = $e->getMessage();
  648. }
  649. return $value;
  650. }
  651. /**
  652. * Sets the default time for an object built from date-only data.
  653. *
  654. * The default time for a date without time can be anything, so long as it is
  655. * consistently applied. If we use noon, dates in most timezones will have the
  656. * same value for in both the local timezone and UTC.
  657. */
  658. public function setDefaultDateTime() {
  659. $this->dateTimeObject->setTime(12, 0, 0);
  660. }
  661. /**
  662. * Gets a clone of the proxied PHP \DateTime object wrapped by this class.
  663. *
  664. * @return \DateTime
  665. * A clone of the wrapped PHP \DateTime object.
  666. */
  667. public function getPhpDateTime() {
  668. return clone $this->dateTimeObject;
  669. }
  670. }