添加网站文件

This commit is contained in:
2025-12-22 13:59:40 +08:00
commit 117aaf83d1
19468 changed files with 2111999 additions and 0 deletions

View File

@@ -0,0 +1,16 @@
root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
indent_style = space
indent_size = 4
trim_trailing_whitespace = true
[*.md]
trim_trailing_whitespace = false
[*.yml]
indent_style = space
indent_size = 2

View File

@@ -0,0 +1,36 @@
# Change Log
## [1.2.0] - 2017-01-22
### Added
- Added IDE, CodeSniffer, and StyleCI.IO support
### Changed
- Switched to PSR-4 Autoloading
### Fixed
- 0 step expressions are handled better
- Fixed `DayOfMonth` validation to be more strict
- Typos
## [1.1.0] - 2016-01-26
### Added
- Support for non-hourly offset timezones
- Checks for valid expressions
### Changed
- Max Iterations no longer hardcoded for `getRunDate()`
- Supports DateTimeImmutable for newer PHP verions
### Fixed
- Fixed looping bug for PHP 7 when determining the last specified weekday of a month
## [1.0.3] - 2013-11-23
### Added
- Now supports expressions with any number of extra spaces, tabs, or newlines
### Changed
- Using static instead of self in `CronExpression::factory`
### Fixed
- Fixes issue [#28](https://github.com/mtdowling/cron-expression/issues/28) where PHP increments of ranges were failing due to PHP casting hyphens to 0
- Only set default timezone if the given $currentTime is not a DateTime instance ([#34](https://github.com/mtdowling/cron-expression/issues/34))

View File

@@ -0,0 +1,19 @@
Copyright (c) 2011 Michael Dowling <mtdowling@gmail.com> and contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.

View File

@@ -0,0 +1,71 @@
PHP Cron Expression Parser
==========================
[![Latest Stable Version](https://poser.pugx.org/mtdowling/cron-expression/v/stable.png)](https://packagist.org/packages/mtdowling/cron-expression) [![Total Downloads](https://poser.pugx.org/mtdowling/cron-expression/downloads.png)](https://packagist.org/packages/mtdowling/cron-expression) [![Build Status](https://secure.travis-ci.org/mtdowling/cron-expression.png)](http://travis-ci.org/mtdowling/cron-expression)
The PHP cron expression parser can parse a CRON expression, determine if it is
due to run, calculate the next run date of the expression, and calculate the previous
run date of the expression. You can calculate dates far into the future or past by
skipping n number of matching dates.
The parser can handle increments of ranges (e.g. */12, 2-59/3), intervals (e.g. 0-9),
lists (e.g. 1,2,3), W to find the nearest weekday for a given day of the month, L to
find the last day of the month, L to find the last given weekday of a month, and hash
(#) to find the nth weekday of a given month.
Installing
==========
Add the dependency to your project:
```bash
composer require mtdowling/cron-expression
```
Usage
=====
```php
<?php
require_once '/vendor/autoload.php';
// Works with predefined scheduling definitions
$cron = Cron\CronExpression::factory('@daily');
$cron->isDue();
echo $cron->getNextRunDate()->format('Y-m-d H:i:s');
echo $cron->getPreviousRunDate()->format('Y-m-d H:i:s');
// Works with complex expressions
$cron = Cron\CronExpression::factory('3-59/15 2,6-12 */15 1 2-5');
echo $cron->getNextRunDate()->format('Y-m-d H:i:s');
// Calculate a run date two iterations into the future
$cron = Cron\CronExpression::factory('@daily');
echo $cron->getNextRunDate(null, 2)->format('Y-m-d H:i:s');
// Calculate a run date relative to a specific time
$cron = Cron\CronExpression::factory('@monthly');
echo $cron->getNextRunDate('2010-01-12 00:00:00')->format('Y-m-d H:i:s');
```
CRON Expressions
================
A CRON expression is a string representing the schedule for a particular command to execute. The parts of a CRON schedule are as follows:
* * * * * *
- - - - - -
| | | | | |
| | | | | + year [optional]
| | | | +----- day of week (0 - 7) (Sunday=0 or 7)
| | | +---------- month (1 - 12)
| | +--------------- day of month (1 - 31)
| +-------------------- hour (0 - 23)
+------------------------- min (0 - 59)
Requirements
============
- PHP 5.3+
- PHPUnit is required to run the unit tests
- Composer is required to run the unit tests

View File

@@ -0,0 +1,29 @@
{
"name": "mtdowling/cron-expression",
"type": "library",
"description": "CRON for PHP: Calculate the next or previous run date and determine if a CRON expression is due",
"keywords": ["cron", "schedule"],
"license": "MIT",
"authors": [{
"name": "Michael Dowling",
"email": "mtdowling@gmail.com",
"homepage": "https://github.com/mtdowling"
}],
"require": {
"php": ">=5.3.2"
},
"require-dev": {
"phpunit/phpunit": "~4.0|~5.0"
},
"autoload": {
"psr-4": {
"Cron\\": "src/Cron/"
}
},
"autoload-dev": {
"psr-4": {
"Tests\\": "tests/Cron/"
}
},
"abandoned": "dragonmantank/cron-expression"
}

View File

@@ -0,0 +1,148 @@
<?php
namespace Cron;
/**
* Abstract CRON expression field
*/
abstract class AbstractField implements FieldInterface
{
/**
* Check to see if a field is satisfied by a value
*
* @param string $dateValue Date value to check
* @param string $value Value to test
*
* @return bool
*/
public function isSatisfied($dateValue, $value)
{
if ($this->isIncrementsOfRanges($value)) {
return $this->isInIncrementsOfRanges($dateValue, $value);
} elseif ($this->isRange($value)) {
return $this->isInRange($dateValue, $value);
}
return $value == '*' || $dateValue == $value;
}
/**
* Check if a value is a range
*
* @param string $value Value to test
*
* @return bool
*/
public function isRange($value)
{
return strpos($value, '-') !== false;
}
/**
* Check if a value is an increments of ranges
*
* @param string $value Value to test
*
* @return bool
*/
public function isIncrementsOfRanges($value)
{
return strpos($value, '/') !== false;
}
/**
* Test if a value is within a range
*
* @param string $dateValue Set date value
* @param string $value Value to test
*
* @return bool
*/
public function isInRange($dateValue, $value)
{
$parts = array_map('trim', explode('-', $value, 2));
return $dateValue >= $parts[0] && $dateValue <= $parts[1];
}
/**
* Test if a value is within an increments of ranges (offset[-to]/step size)
*
* @param string $dateValue Set date value
* @param string $value Value to test
*
* @return bool
*/
public function isInIncrementsOfRanges($dateValue, $value)
{
$parts = array_map('trim', explode('/', $value, 2));
$stepSize = isset($parts[1]) ? (int) $parts[1] : 0;
if ($stepSize === 0) {
return false;
}
if (($parts[0] == '*' || $parts[0] === '0')) {
return (int) $dateValue % $stepSize == 0;
}
$range = explode('-', $parts[0], 2);
$offset = $range[0];
$to = isset($range[1]) ? $range[1] : $dateValue;
// Ensure that the date value is within the range
if ($dateValue < $offset || $dateValue > $to) {
return false;
}
if ($dateValue > $offset && 0 === $stepSize) {
return false;
}
for ($i = $offset; $i <= $to; $i+= $stepSize) {
if ($i == $dateValue) {
return true;
}
}
return false;
}
/**
* Returns a range of values for the given cron expression
*
* @param string $expression The expression to evaluate
* @param int $max Maximum offset for range
*
* @return array
*/
public function getRangeForExpression($expression, $max)
{
$values = array();
if ($this->isRange($expression) || $this->isIncrementsOfRanges($expression)) {
if (!$this->isIncrementsOfRanges($expression)) {
list ($offset, $to) = explode('-', $expression);
$stepSize = 1;
}
else {
$range = array_map('trim', explode('/', $expression, 2));
$stepSize = isset($range[1]) ? $range[1] : 0;
$range = $range[0];
$range = explode('-', $range, 2);
$offset = $range[0];
$to = isset($range[1]) ? $range[1] : $max;
}
$offset = $offset == '*' ? 0 : $offset;
for ($i = $offset; $i <= $to; $i += $stepSize) {
$values[] = $i;
}
sort($values);
}
else {
$values = array($expression);
}
return $values;
}
}

View File

@@ -0,0 +1,389 @@
<?php
namespace Cron;
use DateTime;
use DateTimeImmutable;
use DateTimeZone;
use Exception;
use InvalidArgumentException;
use RuntimeException;
/**
* CRON expression parser that can determine whether or not a CRON expression is
* due to run, the next run date and previous run date of a CRON expression.
* The determinations made by this class are accurate if checked run once per
* minute (seconds are dropped from date time comparisons).
*
* Schedule parts must map to:
* minute [0-59], hour [0-23], day of month, month [1-12|JAN-DEC], day of week
* [1-7|MON-SUN], and an optional year.
*
* @link http://en.wikipedia.org/wiki/Cron
*/
class CronExpression
{
const MINUTE = 0;
const HOUR = 1;
const DAY = 2;
const MONTH = 3;
const WEEKDAY = 4;
const YEAR = 5;
/**
* @var array CRON expression parts
*/
private $cronParts;
/**
* @var FieldFactory CRON field factory
*/
private $fieldFactory;
/**
* @var int Max iteration count when searching for next run date
*/
private $maxIterationCount = 1000;
/**
* @var array Order in which to test of cron parts
*/
private static $order = array(self::YEAR, self::MONTH, self::DAY, self::WEEKDAY, self::HOUR, self::MINUTE);
/**
* Factory method to create a new CronExpression.
*
* @param string $expression The CRON expression to create. There are
* several special predefined values which can be used to substitute the
* CRON expression:
*
* `@yearly`, `@annually` - Run once a year, midnight, Jan. 1 - 0 0 1 1 *
* `@monthly` - Run once a month, midnight, first of month - 0 0 1 * *
* `@weekly` - Run once a week, midnight on Sun - 0 0 * * 0
* `@daily` - Run once a day, midnight - 0 0 * * *
* `@hourly` - Run once an hour, first minute - 0 * * * *
* @param FieldFactory $fieldFactory Field factory to use
*
* @return CronExpression
*/
public static function factory($expression, FieldFactory $fieldFactory = null)
{
$mappings = array(
'@yearly' => '0 0 1 1 *',
'@annually' => '0 0 1 1 *',
'@monthly' => '0 0 1 * *',
'@weekly' => '0 0 * * 0',
'@daily' => '0 0 * * *',
'@hourly' => '0 * * * *'
);
if (isset($mappings[$expression])) {
$expression = $mappings[$expression];
}
return new static($expression, $fieldFactory ?: new FieldFactory());
}
/**
* Validate a CronExpression.
*
* @param string $expression The CRON expression to validate.
*
* @return bool True if a valid CRON expression was passed. False if not.
* @see \Cron\CronExpression::factory
*/
public static function isValidExpression($expression)
{
try {
self::factory($expression);
} catch (InvalidArgumentException $e) {
return false;
}
return true;
}
/**
* Parse a CRON expression
*
* @param string $expression CRON expression (e.g. '8 * * * *')
* @param FieldFactory $fieldFactory Factory to create cron fields
*/
public function __construct($expression, FieldFactory $fieldFactory)
{
$this->fieldFactory = $fieldFactory;
$this->setExpression($expression);
}
/**
* Set or change the CRON expression
*
* @param string $value CRON expression (e.g. 8 * * * *)
*
* @return CronExpression
* @throws \InvalidArgumentException if not a valid CRON expression
*/
public function setExpression($value)
{
$this->cronParts = preg_split('/\s/', $value, -1, PREG_SPLIT_NO_EMPTY);
if (count($this->cronParts) < 5) {
throw new InvalidArgumentException(
$value . ' is not a valid CRON expression'
);
}
foreach ($this->cronParts as $position => $part) {
$this->setPart($position, $part);
}
return $this;
}
/**
* Set part of the CRON expression
*
* @param int $position The position of the CRON expression to set
* @param string $value The value to set
*
* @return CronExpression
* @throws \InvalidArgumentException if the value is not valid for the part
*/
public function setPart($position, $value)
{
if (!$this->fieldFactory->getField($position)->validate($value)) {
throw new InvalidArgumentException(
'Invalid CRON field value ' . $value . ' at position ' . $position
);
}
$this->cronParts[$position] = $value;
return $this;
}
/**
* Set max iteration count for searching next run dates
*
* @param int $maxIterationCount Max iteration count when searching for next run date
*
* @return CronExpression
*/
public function setMaxIterationCount($maxIterationCount)
{
$this->maxIterationCount = $maxIterationCount;
return $this;
}
/**
* Get a next run date relative to the current date or a specific date
*
* @param string|\DateTime $currentTime Relative calculation date
* @param int $nth Number of matches to skip before returning a
* matching next run date. 0, the default, will return the current
* date and time if the next run date falls on the current date and
* time. Setting this value to 1 will skip the first match and go to
* the second match. Setting this value to 2 will skip the first 2
* matches and so on.
* @param bool $allowCurrentDate Set to TRUE to return the current date if
* it matches the cron expression.
*
* @return \DateTime
* @throws \RuntimeException on too many iterations
*/
public function getNextRunDate($currentTime = 'now', $nth = 0, $allowCurrentDate = false)
{
return $this->getRunDate($currentTime, $nth, false, $allowCurrentDate);
}
/**
* Get a previous run date relative to the current date or a specific date
*
* @param string|\DateTime $currentTime Relative calculation date
* @param int $nth Number of matches to skip before returning
* @param bool $allowCurrentDate Set to TRUE to return the
* current date if it matches the cron expression
*
* @return \DateTime
* @throws \RuntimeException on too many iterations
* @see \Cron\CronExpression::getNextRunDate
*/
public function getPreviousRunDate($currentTime = 'now', $nth = 0, $allowCurrentDate = false)
{
return $this->getRunDate($currentTime, $nth, true, $allowCurrentDate);
}
/**
* Get multiple run dates starting at the current date or a specific date
*
* @param int $total Set the total number of dates to calculate
* @param string|\DateTime $currentTime Relative calculation date
* @param bool $invert Set to TRUE to retrieve previous dates
* @param bool $allowCurrentDate Set to TRUE to return the
* current date if it matches the cron expression
*
* @return array Returns an array of run dates
*/
public function getMultipleRunDates($total, $currentTime = 'now', $invert = false, $allowCurrentDate = false)
{
$matches = array();
for ($i = 0; $i < max(0, $total); $i++) {
try {
$matches[] = $this->getRunDate($currentTime, $i, $invert, $allowCurrentDate);
} catch (RuntimeException $e) {
break;
}
}
return $matches;
}
/**
* Get all or part of the CRON expression
*
* @param string $part Specify the part to retrieve or NULL to get the full
* cron schedule string.
*
* @return string|null Returns the CRON expression, a part of the
* CRON expression, or NULL if the part was specified but not found
*/
public function getExpression($part = null)
{
if (null === $part) {
return implode(' ', $this->cronParts);
} elseif (array_key_exists($part, $this->cronParts)) {
return $this->cronParts[$part];
}
return null;
}
/**
* Helper method to output the full expression.
*
* @return string Full CRON expression
*/
public function __toString()
{
return $this->getExpression();
}
/**
* Determine if the cron is due to run based on the current date or a
* specific date. This method assumes that the current number of
* seconds are irrelevant, and should be called once per minute.
*
* @param string|\DateTime $currentTime Relative calculation date
*
* @return bool Returns TRUE if the cron is due to run or FALSE if not
*/
public function isDue($currentTime = 'now')
{
if ('now' === $currentTime) {
$currentDate = date('Y-m-d H:i');
$currentTime = strtotime($currentDate);
} elseif ($currentTime instanceof DateTime) {
$currentDate = clone $currentTime;
// Ensure time in 'current' timezone is used
$currentDate->setTimezone(new DateTimeZone(date_default_timezone_get()));
$currentDate = $currentDate->format('Y-m-d H:i');
$currentTime = strtotime($currentDate);
} elseif ($currentTime instanceof DateTimeImmutable) {
$currentDate = DateTime::createFromFormat('U', $currentTime->format('U'));
$currentDate->setTimezone(new DateTimeZone(date_default_timezone_get()));
$currentDate = $currentDate->format('Y-m-d H:i');
$currentTime = strtotime($currentDate);
} else {
$currentTime = new DateTime($currentTime);
$currentTime->setTime($currentTime->format('H'), $currentTime->format('i'), 0);
$currentDate = $currentTime->format('Y-m-d H:i');
$currentTime = $currentTime->getTimeStamp();
}
try {
return $this->getNextRunDate($currentDate, 0, true)->getTimestamp() == $currentTime;
} catch (Exception $e) {
return false;
}
}
/**
* Get the next or previous run date of the expression relative to a date
*
* @param string|\DateTime $currentTime Relative calculation date
* @param int $nth Number of matches to skip before returning
* @param bool $invert Set to TRUE to go backwards in time
* @param bool $allowCurrentDate Set to TRUE to return the
* current date if it matches the cron expression
*
* @return \DateTime
* @throws \RuntimeException on too many iterations
*/
protected function getRunDate($currentTime = null, $nth = 0, $invert = false, $allowCurrentDate = false)
{
if ($currentTime instanceof DateTime) {
$currentDate = clone $currentTime;
} elseif ($currentTime instanceof DateTimeImmutable) {
$currentDate = DateTime::createFromFormat('U', $currentTime->format('U'));
$currentDate->setTimezone($currentTime->getTimezone());
} else {
$currentDate = new DateTime($currentTime ?: 'now');
$currentDate->setTimezone(new DateTimeZone(date_default_timezone_get()));
}
$currentDate->setTime($currentDate->format('H'), $currentDate->format('i'), 0);
$nextRun = clone $currentDate;
$nth = (int) $nth;
// We don't have to satisfy * or null fields
$parts = array();
$fields = array();
foreach (self::$order as $position) {
$part = $this->getExpression($position);
if (null === $part || '*' === $part) {
continue;
}
$parts[$position] = $part;
$fields[$position] = $this->fieldFactory->getField($position);
}
// Set a hard limit to bail on an impossible date
for ($i = 0; $i < $this->maxIterationCount; $i++) {
foreach ($parts as $position => $part) {
$satisfied = false;
// Get the field object used to validate this part
$field = $fields[$position];
// Check if this is singular or a list
if (strpos($part, ',') === false) {
$satisfied = $field->isSatisfiedBy($nextRun, $part);
} else {
foreach (array_map('trim', explode(',', $part)) as $listPart) {
if ($field->isSatisfiedBy($nextRun, $listPart)) {
$satisfied = true;
break;
}
}
}
// If the field is not satisfied, then start over
if (!$satisfied) {
$field->increment($nextRun, $invert, $part);
continue 2;
}
}
// Skip this match if needed
if ((!$allowCurrentDate && $nextRun == $currentDate) || --$nth > -1) {
$this->fieldFactory->getField(0)->increment($nextRun, $invert, isset($parts[0]) ? $parts[0] : null);
continue;
}
return $nextRun;
}
// @codeCoverageIgnoreStart
throw new RuntimeException('Impossible CRON expression');
// @codeCoverageIgnoreEnd
}
}

View File

@@ -0,0 +1,173 @@
<?php
namespace Cron;
use DateTime;
/**
* Day of month field. Allows: * , / - ? L W
*
* 'L' stands for "last" and specifies the last day of the month.
*
* The 'W' character is used to specify the weekday (Monday-Friday) nearest the
* given day. As an example, if you were to specify "15W" as the value for the
* day-of-month field, the meaning is: "the nearest weekday to the 15th of the
* month". So if the 15th is a Saturday, the trigger will fire on Friday the
* 14th. If the 15th is a Sunday, the trigger will fire on Monday the 16th. If
* the 15th is a Tuesday, then it will fire on Tuesday the 15th. However if you
* specify "1W" as the value for day-of-month, and the 1st is a Saturday, the
* trigger will fire on Monday the 3rd, as it will not 'jump' over the boundary
* of a month's days. The 'W' character can only be specified when the
* day-of-month is a single day, not a range or list of days.
*
* @author Michael Dowling <mtdowling@gmail.com>
*/
class DayOfMonthField extends AbstractField
{
/**
* Get the nearest day of the week for a given day in a month
*
* @param int $currentYear Current year
* @param int $currentMonth Current month
* @param int $targetDay Target day of the month
*
* @return \DateTime Returns the nearest date
*/
private static function getNearestWeekday($currentYear, $currentMonth, $targetDay)
{
$tday = str_pad($targetDay, 2, '0', STR_PAD_LEFT);
$target = DateTime::createFromFormat('Y-m-d', "$currentYear-$currentMonth-$tday");
$currentWeekday = (int) $target->format('N');
if ($currentWeekday < 6) {
return $target;
}
$lastDayOfMonth = $target->format('t');
foreach (array(-1, 1, -2, 2) as $i) {
$adjusted = $targetDay + $i;
if ($adjusted > 0 && $adjusted <= $lastDayOfMonth) {
$target->setDate($currentYear, $currentMonth, $adjusted);
if ($target->format('N') < 6 && $target->format('m') == $currentMonth) {
return $target;
}
}
}
}
public function isSatisfiedBy(DateTime $date, $value)
{
// ? states that the field value is to be skipped
if ($value == '?') {
return true;
}
$fieldValue = $date->format('d');
// Check to see if this is the last day of the month
if ($value == 'L') {
return $fieldValue == $date->format('t');
}
// Check to see if this is the nearest weekday to a particular value
if (strpos($value, 'W')) {
// Parse the target day
$targetDay = substr($value, 0, strpos($value, 'W'));
// Find out if the current day is the nearest day of the week
return $date->format('j') == self::getNearestWeekday(
$date->format('Y'),
$date->format('m'),
$targetDay
)->format('j');
}
return $this->isSatisfied($date->format('d'), $value);
}
public function increment(DateTime $date, $invert = false)
{
if ($invert) {
$date->modify('previous day');
$date->setTime(23, 59);
} else {
$date->modify('next day');
$date->setTime(0, 0);
}
return $this;
}
/**
* Validates that the value is valid for the Day of the Month field
* Days of the month can contain values of 1-31, *, L, or ? by default. This can be augmented with lists via a ',',
* ranges via a '-', or with a '[0-9]W' to specify the closest weekday.
*
* @param string $value
* @return bool
*/
public function validate($value)
{
// Allow wildcards and a single L
if ($value === '?' || $value === '*' || $value === 'L') {
return true;
}
// If you only contain numbers and are within 1-31
if ((bool) preg_match('/^\d{1,2}$/', $value) && ($value >= 1 && $value <= 31)) {
return true;
}
// If you have a -, we will deal with each of your chunks
if ((bool) preg_match('/-/', $value)) {
// We cannot have a range within a list or vice versa
if ((bool) preg_match('/,/', $value)) {
return false;
}
$chunks = explode('-', $value);
foreach ($chunks as $chunk) {
if (!$this->validate($chunk)) {
return false;
}
}
return true;
}
// If you have a comma, we will deal with each value
if ((bool) preg_match('/,/', $value)) {
// We cannot have a range within a list or vice versa
if ((bool) preg_match('/-/', $value)) {
return false;
}
$chunks = explode(',', $value);
foreach ($chunks as $chunk) {
if (!$this->validate($chunk)) {
return false;
}
}
return true;
}
// If you contain a /, we'll deal with it
if ((bool) preg_match('/\//', $value)) {
$chunks = explode('/', $value);
foreach ($chunks as $chunk) {
if (!$this->validate($chunk)) {
return false;
}
}
return true;
}
// If you end in W, make sure that it has a numeric in front of it
if ((bool) preg_match('/^\d{1,2}W$/', $value)) {
return true;
}
return false;
}
}

View File

@@ -0,0 +1,141 @@
<?php
namespace Cron;
use DateTime;
use InvalidArgumentException;
/**
* Day of week field. Allows: * / , - ? L #
*
* Days of the week can be represented as a number 0-7 (0|7 = Sunday)
* or as a three letter string: SUN, MON, TUE, WED, THU, FRI, SAT.
*
* 'L' stands for "last". It allows you to specify constructs such as
* "the last Friday" of a given month.
*
* '#' is allowed for the day-of-week field, and must be followed by a
* number between one and five. It allows you to specify constructs such as
* "the second Friday" of a given month.
*/
class DayOfWeekField extends AbstractField
{
public function isSatisfiedBy(DateTime $date, $value)
{
if ($value == '?') {
return true;
}
// Convert text day of the week values to integers
$value = $this->convertLiterals($value);
$currentYear = $date->format('Y');
$currentMonth = $date->format('m');
$lastDayOfMonth = $date->format('t');
// Find out if this is the last specific weekday of the month
if (strpos($value, 'L')) {
$weekday = str_replace('7', '0', substr($value, 0, strpos($value, 'L')));
$tdate = clone $date;
$tdate->setDate($currentYear, $currentMonth, $lastDayOfMonth);
while ($tdate->format('w') != $weekday) {
$tdateClone = new DateTime();
$tdate = $tdateClone
->setTimezone($tdate->getTimezone())
->setDate($currentYear, $currentMonth, --$lastDayOfMonth);
}
return $date->format('j') == $lastDayOfMonth;
}
// Handle # hash tokens
if (strpos($value, '#')) {
list($weekday, $nth) = explode('#', $value);
// 0 and 7 are both Sunday, however 7 matches date('N') format ISO-8601
if ($weekday === '0') {
$weekday = 7;
}
// Validate the hash fields
if ($weekday < 0 || $weekday > 7) {
throw new InvalidArgumentException("Weekday must be a value between 0 and 7. {$weekday} given");
}
if ($nth > 5) {
throw new InvalidArgumentException('There are never more than 5 of a given weekday in a month');
}
// The current weekday must match the targeted weekday to proceed
if ($date->format('N') != $weekday) {
return false;
}
$tdate = clone $date;
$tdate->setDate($currentYear, $currentMonth, 1);
$dayCount = 0;
$currentDay = 1;
while ($currentDay < $lastDayOfMonth + 1) {
if ($tdate->format('N') == $weekday) {
if (++$dayCount >= $nth) {
break;
}
}
$tdate->setDate($currentYear, $currentMonth, ++$currentDay);
}
return $date->format('j') == $currentDay;
}
// Handle day of the week values
if (strpos($value, '-')) {
$parts = explode('-', $value);
if ($parts[0] == '7') {
$parts[0] = '0';
} elseif ($parts[1] == '0') {
$parts[1] = '7';
}
$value = implode('-', $parts);
}
// Test to see which Sunday to use -- 0 == 7 == Sunday
$format = in_array(7, str_split($value)) ? 'N' : 'w';
$fieldValue = $date->format($format);
return $this->isSatisfied($fieldValue, $value);
}
public function increment(DateTime $date, $invert = false)
{
if ($invert) {
$date->modify('-1 day');
$date->setTime(23, 59, 0);
} else {
$date->modify('+1 day');
$date->setTime(0, 0, 0);
}
return $this;
}
public function validate($value)
{
$value = $this->convertLiterals($value);
foreach (explode(',', $value) as $expr) {
if (!preg_match('/^(\*|[0-7](L?|#[1-5]))([\/\,\-][0-7]+)*$/', $expr)) {
return false;
}
}
return true;
}
private function convertLiterals($string)
{
return str_ireplace(
array('SUN', 'MON', 'TUE', 'WED', 'THU', 'FRI', 'SAT'),
range(0, 6),
$string
);
}
}

View File

@@ -0,0 +1,57 @@
<?php
namespace Cron;
use InvalidArgumentException;
/**
* CRON field factory implementing a flyweight factory
* @link http://en.wikipedia.org/wiki/Cron
*/
class FieldFactory
{
/**
* @var array Cache of instantiated fields
*/
private $fields = array();
/**
* Get an instance of a field object for a cron expression position
*
* @param int $position CRON expression position value to retrieve
*
* @return FieldInterface
* @throws InvalidArgumentException if a position is not valid
*/
public function getField($position)
{
if (!isset($this->fields[$position])) {
switch ($position) {
case 0:
$this->fields[$position] = new MinutesField();
break;
case 1:
$this->fields[$position] = new HoursField();
break;
case 2:
$this->fields[$position] = new DayOfMonthField();
break;
case 3:
$this->fields[$position] = new MonthField();
break;
case 4:
$this->fields[$position] = new DayOfWeekField();
break;
case 5:
$this->fields[$position] = new YearField();
break;
default:
throw new InvalidArgumentException(
$position . ' is not a valid position'
);
}
}
return $this->fields[$position];
}
}

View File

@@ -0,0 +1,40 @@
<?php
namespace Cron;
use DateTime;
/**
* CRON field interface
*/
interface FieldInterface
{
/**
* Check if the respective value of a DateTime field satisfies a CRON exp
*
* @param DateTime $date DateTime object to check
* @param string $value CRON expression to test against
*
* @return bool Returns TRUE if satisfied, FALSE otherwise
*/
public function isSatisfiedBy(DateTime $date, $value);
/**
* When a CRON expression is not satisfied, this method is used to increment
* or decrement a DateTime object by the unit of the cron field
*
* @param DateTime $date DateTime object to change
* @param bool $invert (optional) Set to TRUE to decrement
*
* @return FieldInterface
*/
public function increment(DateTime $date, $invert = false);
/**
* Validates a CRON expression for a given field
*
* @param string $value CRON expression value to validate
*
* @return bool Returns TRUE if valid, FALSE otherwise
*/
public function validate($value);
}

View File

@@ -0,0 +1,71 @@
<?php
namespace Cron;
use DateTime;
use DateTimeZone;
/**
* Hours field. Allows: * , / -
*/
class HoursField extends AbstractField
{
public function isSatisfiedBy(DateTime $date, $value)
{
return $this->isSatisfied($date->format('H'), $value);
}
public function increment(DateTime $date, $invert = false, $parts = null)
{
// Change timezone to UTC temporarily. This will
// allow us to go back or forwards and hour even
// if DST will be changed between the hours.
if (is_null($parts) || $parts == '*') {
$timezone = $date->getTimezone();
$date->setTimezone(new DateTimeZone('UTC'));
if ($invert) {
$date->modify('-1 hour');
} else {
$date->modify('+1 hour');
}
$date->setTimezone($timezone);
$date->setTime($date->format('H'), $invert ? 59 : 0);
return $this;
}
$parts = strpos($parts, ',') !== false ? explode(',', $parts) : array($parts);
$hours = array();
foreach ($parts as $part) {
$hours = array_merge($hours, $this->getRangeForExpression($part, 23));
}
$current_hour = $date->format('H');
$position = $invert ? count($hours) - 1 : 0;
if (count($hours) > 1) {
for ($i = 0; $i < count($hours) - 1; $i++) {
if ((!$invert && $current_hour >= $hours[$i] && $current_hour < $hours[$i + 1]) ||
($invert && $current_hour > $hours[$i] && $current_hour <= $hours[$i + 1])) {
$position = $invert ? $i : $i + 1;
break;
}
}
}
$hour = $hours[$position];
if ((!$invert && $date->format('H') >= $hour) || ($invert && $date->format('H') <= $hour)) {
$date->modify(($invert ? '-' : '+') . '1 day');
$date->setTime($invert ? 23 : 0, $invert ? 59 : 0);
}
else {
$date->setTime($hour, $invert ? 59 : 0);
}
return $this;
}
public function validate($value)
{
return (bool) preg_match('/^[\*,\/\-0-9]+$/', $value);
}
}

View File

@@ -0,0 +1,62 @@
<?php
namespace Cron;
use DateTime;
/**
* Minutes field. Allows: * , / -
*/
class MinutesField extends AbstractField
{
public function isSatisfiedBy(DateTime $date, $value)
{
return $this->isSatisfied($date->format('i'), $value);
}
public function increment(DateTime $date, $invert = false, $parts = null)
{
if (is_null($parts)) {
if ($invert) {
$date->modify('-1 minute');
} else {
$date->modify('+1 minute');
}
return $this;
}
$parts = strpos($parts, ',') !== false ? explode(',', $parts) : array($parts);
$minutes = array();
foreach ($parts as $part) {
$minutes = array_merge($minutes, $this->getRangeForExpression($part, 59));
}
$current_minute = $date->format('i');
$position = $invert ? count($minutes) - 1 : 0;
if (count($minutes) > 1) {
for ($i = 0; $i < count($minutes) - 1; $i++) {
if ((!$invert && $current_minute >= $minutes[$i] && $current_minute < $minutes[$i + 1]) ||
($invert && $current_minute > $minutes[$i] && $current_minute <= $minutes[$i + 1])) {
$position = $invert ? $i : $i + 1;
break;
}
}
}
if ((!$invert && $current_minute >= $minutes[$position]) || ($invert && $current_minute <= $minutes[$position])) {
$date->modify(($invert ? '-' : '+') . '1 hour');
$date->setTime($date->format('H'), $invert ? 59 : 0);
}
else {
$date->setTime($date->format('H'), $minutes[$position]);
}
return $this;
}
public function validate($value)
{
return (bool) preg_match('/^[\*,\/\-0-9]+$/', $value);
}
}

View File

@@ -0,0 +1,44 @@
<?php
namespace Cron;
use DateTime;
/**
* Month field. Allows: * , / -
*/
class MonthField extends AbstractField
{
public function isSatisfiedBy(DateTime $date, $value)
{
// Convert text month values to integers
$value = str_ireplace(
array(
'JAN', 'FEB', 'MAR', 'APR', 'MAY', 'JUN',
'JUL', 'AUG', 'SEP', 'OCT', 'NOV', 'DEC'
),
range(1, 12),
$value
);
return $this->isSatisfied($date->format('m'), $value);
}
public function increment(DateTime $date, $invert = false)
{
if ($invert) {
$date->modify('last day of previous month');
$date->setTime(23, 59);
} else {
$date->modify('first day of next month');
$date->setTime(0, 0);
}
return $this;
}
public function validate($value)
{
return (bool) preg_match('/^[\*,\/\-0-9A-Z]+$/', $value);
}
}

View File

@@ -0,0 +1,37 @@
<?php
namespace Cron;
use DateTime;
/**
* Year field. Allows: * , / -
*/
class YearField extends AbstractField
{
public function isSatisfiedBy(DateTime $date, $value)
{
return $this->isSatisfied($date->format('Y'), $value);
}
public function increment(DateTime $date, $invert = false)
{
if ($invert) {
$date->modify('-1 year');
$date->setDate($date->format('Y'), 12, 31);
$date->setTime(23, 59, 0);
} else {
$date->modify('+1 year');
$date->setDate($date->format('Y'), 1, 1);
$date->setTime(0, 0, 0);
}
return $this;
}
public function validate($value)
{
return (bool) preg_match('/^[\*,\/\-0-9]+$/', $value);
}
}

View File

@@ -0,0 +1,86 @@
<?php
namespace Cron\Tests;
use Cron\DayOfWeekField;
use PHPUnit_Framework_TestCase;
/**
* @author Michael Dowling <mtdowling@gmail.com>
*/
class AbstractFieldTest extends PHPUnit_Framework_TestCase
{
/**
* @covers Cron\AbstractField::isRange
*/
public function testTestsIfRange()
{
$f = new DayOfWeekField();
$this->assertTrue($f->isRange('1-2'));
$this->assertFalse($f->isRange('2'));
}
/**
* @covers Cron\AbstractField::isIncrementsOfRanges
*/
public function testTestsIfIncrementsOfRanges()
{
$f = new DayOfWeekField();
$this->assertFalse($f->isIncrementsOfRanges('1-2'));
$this->assertTrue($f->isIncrementsOfRanges('1/2'));
$this->assertTrue($f->isIncrementsOfRanges('*/2'));
$this->assertTrue($f->isIncrementsOfRanges('3-12/2'));
}
/**
* @covers Cron\AbstractField::isInRange
*/
public function testTestsIfInRange()
{
$f = new DayOfWeekField();
$this->assertTrue($f->isInRange('1', '1-2'));
$this->assertTrue($f->isInRange('2', '1-2'));
$this->assertTrue($f->isInRange('5', '4-12'));
$this->assertFalse($f->isInRange('3', '4-12'));
$this->assertFalse($f->isInRange('13', '4-12'));
}
/**
* @covers Cron\AbstractField::isInIncrementsOfRanges
*/
public function testTestsIfInIncrementsOfRanges()
{
$f = new DayOfWeekField();
$this->assertTrue($f->isInIncrementsOfRanges('3', '3-59/2'));
$this->assertTrue($f->isInIncrementsOfRanges('13', '3-59/2'));
$this->assertTrue($f->isInIncrementsOfRanges('15', '3-59/2'));
$this->assertTrue($f->isInIncrementsOfRanges('14', '*/2'));
$this->assertFalse($f->isInIncrementsOfRanges('2', '3-59/13'));
$this->assertFalse($f->isInIncrementsOfRanges('14', '*/13'));
$this->assertFalse($f->isInIncrementsOfRanges('14', '3-59/2'));
$this->assertFalse($f->isInIncrementsOfRanges('3', '2-59'));
$this->assertFalse($f->isInIncrementsOfRanges('3', '2'));
$this->assertFalse($f->isInIncrementsOfRanges('3', '*'));
$this->assertFalse($f->isInIncrementsOfRanges('0', '*/0'));
$this->assertFalse($f->isInIncrementsOfRanges('1', '*/0'));
$this->assertTrue($f->isInIncrementsOfRanges('4', '4/10'));
$this->assertTrue($f->isInIncrementsOfRanges('14', '4/10'));
$this->assertTrue($f->isInIncrementsOfRanges('34', '4/10'));
}
/**
* @covers Cron\AbstractField::isSatisfied
*/
public function testTestsIfSatisfied()
{
$f = new DayOfWeekField();
$this->assertTrue($f->isSatisfied('12', '3-13'));
$this->assertTrue($f->isSatisfied('15', '3-59/12'));
$this->assertTrue($f->isSatisfied('12', '*'));
$this->assertTrue($f->isSatisfied('12', '12'));
$this->assertFalse($f->isSatisfied('12', '3-11'));
$this->assertFalse($f->isSatisfied('12', '3-59/13'));
$this->assertFalse($f->isSatisfied('12', '11'));
}
}

View File

@@ -0,0 +1,414 @@
<?php
namespace Cron\Tests;
use Cron\CronExpression;
use DateTime;
use DateTimeZone;
use InvalidArgumentException;
use PHPUnit_Framework_TestCase;
/**
* @author Michael Dowling <mtdowling@gmail.com>
*/
class CronExpressionTest extends PHPUnit_Framework_TestCase
{
/**
* @covers Cron\CronExpression::factory
*/
public function testFactoryRecognizesTemplates()
{
$this->assertEquals('0 0 1 1 *', CronExpression::factory('@annually')->getExpression());
$this->assertEquals('0 0 1 1 *', CronExpression::factory('@yearly')->getExpression());
$this->assertEquals('0 0 * * 0', CronExpression::factory('@weekly')->getExpression());
}
/**
* @covers Cron\CronExpression::__construct
* @covers Cron\CronExpression::getExpression
* @covers Cron\CronExpression::__toString
*/
public function testParsesCronSchedule()
{
// '2010-09-10 12:00:00'
$cron = CronExpression::factory('1 2-4 * 4,5,6 */3');
$this->assertEquals('1', $cron->getExpression(CronExpression::MINUTE));
$this->assertEquals('2-4', $cron->getExpression(CronExpression::HOUR));
$this->assertEquals('*', $cron->getExpression(CronExpression::DAY));
$this->assertEquals('4,5,6', $cron->getExpression(CronExpression::MONTH));
$this->assertEquals('*/3', $cron->getExpression(CronExpression::WEEKDAY));
$this->assertEquals('1 2-4 * 4,5,6 */3', $cron->getExpression());
$this->assertEquals('1 2-4 * 4,5,6 */3', (string) $cron);
$this->assertNull($cron->getExpression('foo'));
try {
$cron = CronExpression::factory('A 1 2 3 4');
$this->fail('Validation exception not thrown');
} catch (InvalidArgumentException $e) {
}
}
/**
* @covers Cron\CronExpression::__construct
* @covers Cron\CronExpression::getExpression
* @dataProvider scheduleWithDifferentSeparatorsProvider
*/
public function testParsesCronScheduleWithAnySpaceCharsAsSeparators($schedule, array $expected)
{
$cron = CronExpression::factory($schedule);
$this->assertEquals($expected[0], $cron->getExpression(CronExpression::MINUTE));
$this->assertEquals($expected[1], $cron->getExpression(CronExpression::HOUR));
$this->assertEquals($expected[2], $cron->getExpression(CronExpression::DAY));
$this->assertEquals($expected[3], $cron->getExpression(CronExpression::MONTH));
$this->assertEquals($expected[4], $cron->getExpression(CronExpression::WEEKDAY));
$this->assertEquals($expected[5], $cron->getExpression(CronExpression::YEAR));
}
/**
* Data provider for testParsesCronScheduleWithAnySpaceCharsAsSeparators
*
* @return array
*/
public static function scheduleWithDifferentSeparatorsProvider()
{
return array(
array("*\t*\t*\t*\t*\t*", array('*', '*', '*', '*', '*', '*')),
array("* * * * * *", array('*', '*', '*', '*', '*', '*')),
array("* \t * \t * \t * \t * \t *", array('*', '*', '*', '*', '*', '*')),
array("*\t \t*\t \t*\t \t*\t \t*\t \t*", array('*', '*', '*', '*', '*', '*')),
);
}
/**
* @covers Cron\CronExpression::__construct
* @covers Cron\CronExpression::setExpression
* @covers Cron\CronExpression::setPart
* @expectedException InvalidArgumentException
*/
public function testInvalidCronsWillFail()
{
// Only four values
$cron = CronExpression::factory('* * * 1');
}
/**
* @covers Cron\CronExpression::setPart
* @expectedException InvalidArgumentException
*/
public function testInvalidPartsWillFail()
{
// Only four values
$cron = CronExpression::factory('* * * * *');
$cron->setPart(1, 'abc');
}
/**
* Data provider for cron schedule
*
* @return array
*/
public function scheduleProvider()
{
return array(
array('*/2 */2 * * *', '2015-08-10 21:47:27', '2015-08-10 22:00:00', false),
array('* * * * *', '2015-08-10 21:50:37', '2015-08-10 21:50:00', true),
array('* 20,21,22 * * *', '2015-08-10 21:50:00', '2015-08-10 21:50:00', true),
// Handles CSV values
array('* 20,22 * * *', '2015-08-10 21:50:00', '2015-08-10 22:00:00', false),
// CSV values can be complex
array('* 5,21-22 * * *', '2015-08-10 21:50:00', '2015-08-10 21:50:00', true),
array('7-9 * */9 * *', '2015-08-10 22:02:33', '2015-08-18 00:07:00', false),
// 15th minute, of the second hour, every 15 days, in January, every Friday
array('1 * * * 7', '2015-08-10 21:47:27', '2015-08-16 00:01:00', false),
// Test with exact times
array('47 21 * * *', strtotime('2015-08-10 21:47:30'), '2015-08-10 21:47:00', true),
// Test Day of the week (issue #1)
// According cron implementation, 0|7 = sunday, 1 => monday, etc
array('* * * * 0', strtotime('2011-06-15 23:09:00'), '2011-06-19 00:00:00', false),
array('* * * * 7', strtotime('2011-06-15 23:09:00'), '2011-06-19 00:00:00', false),
array('* * * * 1', strtotime('2011-06-15 23:09:00'), '2011-06-20 00:00:00', false),
// Should return the sunday date as 7 equals 0
array('0 0 * * MON,SUN', strtotime('2011-06-15 23:09:00'), '2011-06-19 00:00:00', false),
array('0 0 * * 1,7', strtotime('2011-06-15 23:09:00'), '2011-06-19 00:00:00', false),
array('0 0 * * 0-4', strtotime('2011-06-15 23:09:00'), '2011-06-16 00:00:00', false),
array('0 0 * * 7-4', strtotime('2011-06-15 23:09:00'), '2011-06-16 00:00:00', false),
array('0 0 * * 4-7', strtotime('2011-06-15 23:09:00'), '2011-06-16 00:00:00', false),
array('0 0 * * 7-3', strtotime('2011-06-15 23:09:00'), '2011-06-19 00:00:00', false),
array('0 0 * * 3-7', strtotime('2011-06-15 23:09:00'), '2011-06-16 00:00:00', false),
array('0 0 * * 3-7', strtotime('2011-06-18 23:09:00'), '2011-06-19 00:00:00', false),
// Test lists of values and ranges (Abhoryo)
array('0 0 * * 2-7', strtotime('2011-06-20 23:09:00'), '2011-06-21 00:00:00', false),
array('0 0 * * 0,2-6', strtotime('2011-06-20 23:09:00'), '2011-06-21 00:00:00', false),
array('0 0 * * 2-7', strtotime('2011-06-18 23:09:00'), '2011-06-19 00:00:00', false),
array('0 0 * * 4-7', strtotime('2011-07-19 00:00:00'), '2011-07-21 00:00:00', false),
// Test increments of ranges
array('0-12/4 * * * *', strtotime('2011-06-20 12:04:00'), '2011-06-20 12:04:00', true),
array('4-59/2 * * * *', strtotime('2011-06-20 12:04:00'), '2011-06-20 12:04:00', true),
array('4-59/2 * * * *', strtotime('2011-06-20 12:06:00'), '2011-06-20 12:06:00', true),
array('4-59/3 * * * *', strtotime('2011-06-20 12:06:00'), '2011-06-20 12:07:00', false),
//array('0 0 * * 0,2-6', strtotime('2011-06-20 23:09:00'), '2011-06-21 00:00:00', false),
// Test Day of the Week and the Day of the Month (issue #1)
array('0 0 1 1 0', strtotime('2011-06-15 23:09:00'), '2012-01-01 00:00:00', false),
array('0 0 1 JAN 0', strtotime('2011-06-15 23:09:00'), '2012-01-01 00:00:00', false),
array('0 0 1 * 0', strtotime('2011-06-15 23:09:00'), '2012-01-01 00:00:00', false),
array('0 0 L * *', strtotime('2011-07-15 00:00:00'), '2011-07-31 00:00:00', false),
// Test the W day of the week modifier for day of the month field
array('0 0 2W * *', strtotime('2011-07-01 00:00:00'), '2011-07-01 00:00:00', true),
array('0 0 1W * *', strtotime('2011-05-01 00:00:00'), '2011-05-02 00:00:00', false),
array('0 0 1W * *', strtotime('2011-07-01 00:00:00'), '2011-07-01 00:00:00', true),
array('0 0 3W * *', strtotime('2011-07-01 00:00:00'), '2011-07-04 00:00:00', false),
array('0 0 16W * *', strtotime('2011-07-01 00:00:00'), '2011-07-15 00:00:00', false),
array('0 0 28W * *', strtotime('2011-07-01 00:00:00'), '2011-07-28 00:00:00', false),
array('0 0 30W * *', strtotime('2011-07-01 00:00:00'), '2011-07-29 00:00:00', false),
array('0 0 31W * *', strtotime('2011-07-01 00:00:00'), '2011-07-29 00:00:00', false),
// Test the year field
array('* * * * * 2012', strtotime('2011-05-01 00:00:00'), '2012-01-01 00:00:00', false),
// Test the last weekday of a month
array('* * * * 5L', strtotime('2011-07-01 00:00:00'), '2011-07-29 00:00:00', false),
array('* * * * 6L', strtotime('2011-07-01 00:00:00'), '2011-07-30 00:00:00', false),
array('* * * * 7L', strtotime('2011-07-01 00:00:00'), '2011-07-31 00:00:00', false),
array('* * * * 1L', strtotime('2011-07-24 00:00:00'), '2011-07-25 00:00:00', false),
array('* * * * TUEL', strtotime('2011-07-24 00:00:00'), '2011-07-26 00:00:00', false),
array('* * * 1 5L', strtotime('2011-12-25 00:00:00'), '2012-01-27 00:00:00', false),
// Test the hash symbol for the nth weekday of a given month
array('* * * * 5#2', strtotime('2011-07-01 00:00:00'), '2011-07-08 00:00:00', false),
array('* * * * 5#1', strtotime('2011-07-01 00:00:00'), '2011-07-01 00:00:00', true),
array('* * * * 3#4', strtotime('2011-07-01 00:00:00'), '2011-07-27 00:00:00', false),
);
}
/**
* @covers Cron\CronExpression::isDue
* @covers Cron\CronExpression::getNextRunDate
* @covers Cron\DayOfMonthField
* @covers Cron\DayOfWeekField
* @covers Cron\MinutesField
* @covers Cron\HoursField
* @covers Cron\MonthField
* @covers Cron\YearField
* @covers Cron\CronExpression::getRunDate
* @dataProvider scheduleProvider
*/
public function testDeterminesIfCronIsDue($schedule, $relativeTime, $nextRun, $isDue)
{
$relativeTimeString = is_int($relativeTime) ? date('Y-m-d H:i:s', $relativeTime) : $relativeTime;
// Test next run date
$cron = CronExpression::factory($schedule);
if (is_string($relativeTime)) {
$relativeTime = new DateTime($relativeTime);
} elseif (is_int($relativeTime)) {
$relativeTime = date('Y-m-d H:i:s', $relativeTime);
}
$this->assertEquals($isDue, $cron->isDue($relativeTime));
$next = $cron->getNextRunDate($relativeTime, 0, true);
$this->assertEquals(new DateTime($nextRun), $next);
}
/**
* @covers Cron\CronExpression::isDue
*/
public function testIsDueHandlesDifferentDates()
{
$cron = CronExpression::factory('* * * * *');
$this->assertTrue($cron->isDue());
$this->assertTrue($cron->isDue('now'));
$this->assertTrue($cron->isDue(new DateTime('now')));
$this->assertTrue($cron->isDue(date('Y-m-d H:i')));
}
/**
* @covers Cron\CronExpression::isDue
*/
public function testIsDueHandlesDifferentTimezones()
{
$cron = CronExpression::factory('0 15 * * 3'); //Wednesday at 15:00
$date = '2014-01-01 15:00'; //Wednesday
$utc = new DateTimeZone('UTC');
$amsterdam = new DateTimeZone('Europe/Amsterdam');
$tokyo = new DateTimeZone('Asia/Tokyo');
date_default_timezone_set('UTC');
$this->assertTrue($cron->isDue(new DateTime($date, $utc)));
$this->assertFalse($cron->isDue(new DateTime($date, $amsterdam)));
$this->assertFalse($cron->isDue(new DateTime($date, $tokyo)));
date_default_timezone_set('Europe/Amsterdam');
$this->assertFalse($cron->isDue(new DateTime($date, $utc)));
$this->assertTrue($cron->isDue(new DateTime($date, $amsterdam)));
$this->assertFalse($cron->isDue(new DateTime($date, $tokyo)));
date_default_timezone_set('Asia/Tokyo');
$this->assertFalse($cron->isDue(new DateTime($date, $utc)));
$this->assertFalse($cron->isDue(new DateTime($date, $amsterdam)));
$this->assertTrue($cron->isDue(new DateTime($date, $tokyo)));
}
/**
* @covers Cron\CronExpression::getPreviousRunDate
*/
public function testCanGetPreviousRunDates()
{
$cron = CronExpression::factory('* * * * *');
$next = $cron->getNextRunDate('now');
$two = $cron->getNextRunDate('now', 1);
$this->assertEquals($next, $cron->getPreviousRunDate($two));
$cron = CronExpression::factory('* */2 * * *');
$next = $cron->getNextRunDate('now');
$two = $cron->getNextRunDate('now', 1);
$this->assertEquals($next, $cron->getPreviousRunDate($two));
$cron = CronExpression::factory('* * * */2 *');
$next = $cron->getNextRunDate('now');
$two = $cron->getNextRunDate('now', 1);
$this->assertEquals($next, $cron->getPreviousRunDate($two));
}
/**
* @covers Cron\CronExpression::getMultipleRunDates
*/
public function testProvidesMultipleRunDates()
{
$cron = CronExpression::factory('*/2 * * * *');
$this->assertEquals(array(
new DateTime('2008-11-09 00:00:00'),
new DateTime('2008-11-09 00:02:00'),
new DateTime('2008-11-09 00:04:00'),
new DateTime('2008-11-09 00:06:00')
), $cron->getMultipleRunDates(4, '2008-11-09 00:00:00', false, true));
}
/**
* @covers Cron\CronExpression::getMultipleRunDates
* @covers Cron\CronExpression::setMaxIterationCount
*/
public function testProvidesMultipleRunDatesForTheFarFuture() {
// Fails with the default 1000 iteration limit
$cron = CronExpression::factory('0 0 12 1 * */2');
$cron->setMaxIterationCount(2000);
$this->assertEquals(array(
new DateTime('2016-01-12 00:00:00'),
new DateTime('2018-01-12 00:00:00'),
new DateTime('2020-01-12 00:00:00'),
new DateTime('2022-01-12 00:00:00'),
new DateTime('2024-01-12 00:00:00'),
new DateTime('2026-01-12 00:00:00'),
new DateTime('2028-01-12 00:00:00'),
new DateTime('2030-01-12 00:00:00'),
new DateTime('2032-01-12 00:00:00'),
), $cron->getMultipleRunDates(9, '2015-04-28 00:00:00', false, true));
}
/**
* @covers Cron\CronExpression
*/
public function testCanIterateOverNextRuns()
{
$cron = CronExpression::factory('@weekly');
$nextRun = $cron->getNextRunDate("2008-11-09 08:00:00");
$this->assertEquals($nextRun, new DateTime("2008-11-16 00:00:00"));
// true is cast to 1
$nextRun = $cron->getNextRunDate("2008-11-09 00:00:00", true, true);
$this->assertEquals($nextRun, new DateTime("2008-11-16 00:00:00"));
// You can iterate over them
$nextRun = $cron->getNextRunDate($cron->getNextRunDate("2008-11-09 00:00:00", 1, true), 1, true);
$this->assertEquals($nextRun, new DateTime("2008-11-23 00:00:00"));
// You can skip more than one
$nextRun = $cron->getNextRunDate("2008-11-09 00:00:00", 2, true);
$this->assertEquals($nextRun, new DateTime("2008-11-23 00:00:00"));
$nextRun = $cron->getNextRunDate("2008-11-09 00:00:00", 3, true);
$this->assertEquals($nextRun, new DateTime("2008-11-30 00:00:00"));
}
/**
* @covers Cron\CronExpression::getRunDate
*/
public function testSkipsCurrentDateByDefault()
{
$cron = CronExpression::factory('* * * * *');
$current = new DateTime('now');
$next = $cron->getNextRunDate($current);
$nextPrev = $cron->getPreviousRunDate($next);
$this->assertEquals($current->format('Y-m-d H:i:00'), $nextPrev->format('Y-m-d H:i:s'));
}
/**
* @covers Cron\CronExpression::getRunDate
* @ticket 7
*/
public function testStripsForSeconds()
{
$cron = CronExpression::factory('* * * * *');
$current = new DateTime('2011-09-27 10:10:54');
$this->assertEquals('2011-09-27 10:11:00', $cron->getNextRunDate($current)->format('Y-m-d H:i:s'));
}
/**
* @covers Cron\CronExpression::getRunDate
*/
public function testFixesPhpBugInDateIntervalMonth()
{
$cron = CronExpression::factory('0 0 27 JAN *');
$this->assertEquals('2011-01-27 00:00:00', $cron->getPreviousRunDate('2011-08-22 00:00:00')->format('Y-m-d H:i:s'));
}
public function testIssue29()
{
$cron = CronExpression::factory('@weekly');
$this->assertEquals(
'2013-03-10 00:00:00',
$cron->getPreviousRunDate('2013-03-17 00:00:00')->format('Y-m-d H:i:s')
);
}
/**
* @see https://github.com/mtdowling/cron-expression/issues/20
*/
public function testIssue20() {
$e = CronExpression::factory('* * * * MON#1');
$this->assertTrue($e->isDue(new DateTime('2014-04-07 00:00:00')));
$this->assertFalse($e->isDue(new DateTime('2014-04-14 00:00:00')));
$this->assertFalse($e->isDue(new DateTime('2014-04-21 00:00:00')));
$e = CronExpression::factory('* * * * SAT#2');
$this->assertFalse($e->isDue(new DateTime('2014-04-05 00:00:00')));
$this->assertTrue($e->isDue(new DateTime('2014-04-12 00:00:00')));
$this->assertFalse($e->isDue(new DateTime('2014-04-19 00:00:00')));
$e = CronExpression::factory('* * * * SUN#3');
$this->assertFalse($e->isDue(new DateTime('2014-04-13 00:00:00')));
$this->assertTrue($e->isDue(new DateTime('2014-04-20 00:00:00')));
$this->assertFalse($e->isDue(new DateTime('2014-04-27 00:00:00')));
}
/**
* @covers Cron\CronExpression::getRunDate
*/
public function testKeepOriginalTime()
{
$now = new \DateTime;
$strNow = $now->format(DateTime::ISO8601);
$cron = CronExpression::factory('0 0 * * *');
$cron->getPreviousRunDate($now);
$this->assertEquals($strNow, $now->format(DateTime::ISO8601));
}
/**
* @covers Cron\CronExpression::__construct
* @covers Cron\CronExpression::factory
* @covers Cron\CronExpression::isValidExpression
* @covers Cron\CronExpression::setExpression
* @covers Cron\CronExpression::setPart
*/
public function testValidationWorks()
{
// Invalid. Only four values
$this->assertFalse(CronExpression::isValidExpression('* * * 1'));
// Valid
$this->assertTrue(CronExpression::isValidExpression('* * * * 1'));
}
}

View File

@@ -0,0 +1,61 @@
<?php
namespace Cron\Tests;
use Cron\DayOfMonthField;
use DateTime;
use PHPUnit_Framework_TestCase;
/**
* @author Michael Dowling <mtdowling@gmail.com>
*/
class DayOfMonthFieldTest extends PHPUnit_Framework_TestCase
{
/**
* @covers Cron\DayOfMonthField::validate
*/
public function testValidatesField()
{
$f = new DayOfMonthField();
$this->assertTrue($f->validate('1'));
$this->assertTrue($f->validate('*'));
$this->assertTrue($f->validate('5W,L'));
$this->assertFalse($f->validate('1.'));
}
/**
* @covers Cron\DayOfMonthField::isSatisfiedBy
*/
public function testChecksIfSatisfied()
{
$f = new DayOfMonthField();
$this->assertTrue($f->isSatisfiedBy(new DateTime(), '?'));
}
/**
* @covers Cron\DayOfMonthField::increment
*/
public function testIncrementsDate()
{
$d = new DateTime('2011-03-15 11:15:00');
$f = new DayOfMonthField();
$f->increment($d);
$this->assertEquals('2011-03-16 00:00:00', $d->format('Y-m-d H:i:s'));
$d = new DateTime('2011-03-15 11:15:00');
$f->increment($d, true);
$this->assertEquals('2011-03-14 23:59:00', $d->format('Y-m-d H:i:s'));
}
/**
* Day of the month cannot accept a 0 value, it must be between 1 and 31
* See Github issue #120
*
* @since 2017-01-22
*/
public function testDoesNotAccept0Date()
{
$f = new DayOfMonthField();
$this->assertFalse($f->validate(0));
}
}

View File

@@ -0,0 +1,117 @@
<?php
namespace Cron\Tests;
use Cron\DayOfWeekField;
use DateTime;
use PHPUnit_Framework_TestCase;
/**
* @author Michael Dowling <mtdowling@gmail.com>
*/
class DayOfWeekFieldTest extends PHPUnit_Framework_TestCase
{
/**
* @covers Cron\DayOfWeekField::validate
*/
public function testValidatesField()
{
$f = new DayOfWeekField();
$this->assertTrue($f->validate('1'));
$this->assertTrue($f->validate('*'));
$this->assertTrue($f->validate('*/3,1,1-12'));
$this->assertTrue($f->validate('SUN-2'));
$this->assertFalse($f->validate('1.'));
}
/**
* @covers Cron\DayOfWeekField::isSatisfiedBy
*/
public function testChecksIfSatisfied()
{
$f = new DayOfWeekField();
$this->assertTrue($f->isSatisfiedBy(new DateTime(), '?'));
}
/**
* @covers Cron\DayOfWeekField::increment
*/
public function testIncrementsDate()
{
$d = new DateTime('2011-03-15 11:15:00');
$f = new DayOfWeekField();
$f->increment($d);
$this->assertEquals('2011-03-16 00:00:00', $d->format('Y-m-d H:i:s'));
$d = new DateTime('2011-03-15 11:15:00');
$f->increment($d, true);
$this->assertEquals('2011-03-14 23:59:00', $d->format('Y-m-d H:i:s'));
}
/**
* @covers Cron\DayOfWeekField::isSatisfiedBy
* @expectedException InvalidArgumentException
* @expectedExceptionMessage Weekday must be a value between 0 and 7. 12 given
*/
public function testValidatesHashValueWeekday()
{
$f = new DayOfWeekField();
$this->assertTrue($f->isSatisfiedBy(new DateTime(), '12#1'));
}
/**
* @covers Cron\DayOfWeekField::isSatisfiedBy
* @expectedException InvalidArgumentException
* @expectedExceptionMessage There are never more than 5 of a given weekday in a month
*/
public function testValidatesHashValueNth()
{
$f = new DayOfWeekField();
$this->assertTrue($f->isSatisfiedBy(new DateTime(), '3#6'));
}
/**
* @covers Cron\DayOfWeekField::validate
*/
public function testValidateWeekendHash()
{
$f = new DayOfWeekField();
$this->assertTrue($f->validate('MON#1'));
$this->assertTrue($f->validate('TUE#2'));
$this->assertTrue($f->validate('WED#3'));
$this->assertTrue($f->validate('THU#4'));
$this->assertTrue($f->validate('FRI#5'));
$this->assertTrue($f->validate('SAT#1'));
$this->assertTrue($f->validate('SUN#3'));
$this->assertTrue($f->validate('MON#1,MON#3'));
}
/**
* @covers Cron\DayOfWeekField::isSatisfiedBy
*/
public function testHandlesZeroAndSevenDayOfTheWeekValues()
{
$f = new DayOfWeekField();
$this->assertTrue($f->isSatisfiedBy(new DateTime('2011-09-04 00:00:00'), '0-2'));
$this->assertTrue($f->isSatisfiedBy(new DateTime('2011-09-04 00:00:00'), '6-0'));
$this->assertTrue($f->isSatisfiedBy(new DateTime('2014-04-20 00:00:00'), 'SUN'));
$this->assertTrue($f->isSatisfiedBy(new DateTime('2014-04-20 00:00:00'), 'SUN#3'));
$this->assertTrue($f->isSatisfiedBy(new DateTime('2014-04-20 00:00:00'), '0#3'));
$this->assertTrue($f->isSatisfiedBy(new DateTime('2014-04-20 00:00:00'), '7#3'));
}
/**
* @see https://github.com/mtdowling/cron-expression/issues/47
*/
public function testIssue47() {
$f = new DayOfWeekField();
$this->assertFalse($f->validate('mon,'));
$this->assertFalse($f->validate('mon-'));
$this->assertFalse($f->validate('*/2,'));
$this->assertFalse($f->validate('-mon'));
$this->assertFalse($f->validate(',1'));
$this->assertFalse($f->validate('*-'));
$this->assertFalse($f->validate(',-'));
}
}

View File

@@ -0,0 +1,43 @@
<?php
namespace Cron\Tests;
use Cron\FieldFactory;
use PHPUnit_Framework_TestCase;
/**
* @author Michael Dowling <mtdowling@gmail.com>
*/
class FieldFactoryTest extends PHPUnit_Framework_TestCase
{
/**
* @covers Cron\FieldFactory::getField
*/
public function testRetrievesFieldInstances()
{
$mappings = array(
0 => 'Cron\MinutesField',
1 => 'Cron\HoursField',
2 => 'Cron\DayOfMonthField',
3 => 'Cron\MonthField',
4 => 'Cron\DayOfWeekField',
5 => 'Cron\YearField'
);
$f = new FieldFactory();
foreach ($mappings as $position => $class) {
$this->assertEquals($class, get_class($f->getField($position)));
}
}
/**
* @covers Cron\FieldFactory::getField
* @expectedException InvalidArgumentException
*/
public function testValidatesFieldPosition()
{
$f = new FieldFactory();
$f->getField(-1);
}
}

View File

@@ -0,0 +1,75 @@
<?php
namespace Cron\Tests;
use Cron\HoursField;
use DateTime;
use PHPUnit_Framework_TestCase;
/**
* @author Michael Dowling <mtdowling@gmail.com>
*/
class HoursFieldTest extends PHPUnit_Framework_TestCase
{
/**
* @covers Cron\HoursField::validate
*/
public function testValidatesField()
{
$f = new HoursField();
$this->assertTrue($f->validate('1'));
$this->assertTrue($f->validate('*'));
$this->assertTrue($f->validate('*/3,1,1-12'));
}
/**
* @covers Cron\HoursField::increment
*/
public function testIncrementsDate()
{
$d = new DateTime('2011-03-15 11:15:00');
$f = new HoursField();
$f->increment($d);
$this->assertEquals('2011-03-15 12:00:00', $d->format('Y-m-d H:i:s'));
$d->setTime(11, 15, 0);
$f->increment($d, true);
$this->assertEquals('2011-03-15 10:59:00', $d->format('Y-m-d H:i:s'));
}
/**
* @covers Cron\HoursField::increment
*/
public function testIncrementsDateWithThirtyMinuteOffsetTimezone()
{
$tz = date_default_timezone_get();
date_default_timezone_set('America/St_Johns');
$d = new DateTime('2011-03-15 11:15:00');
$f = new HoursField();
$f->increment($d);
$this->assertEquals('2011-03-15 12:00:00', $d->format('Y-m-d H:i:s'));
$d->setTime(11, 15, 0);
$f->increment($d, true);
$this->assertEquals('2011-03-15 10:59:00', $d->format('Y-m-d H:i:s'));
date_default_timezone_set($tz);
}
/**
* @covers Cron\HoursField::increment
*/
public function testIncrementDateWithFifteenMinuteOffsetTimezone()
{
$tz = date_default_timezone_get();
date_default_timezone_set('Asia/Kathmandu');
$d = new DateTime('2011-03-15 11:15:00');
$f = new HoursField();
$f->increment($d);
$this->assertEquals('2011-03-15 12:00:00', $d->format('Y-m-d H:i:s'));
$d->setTime(11, 15, 0);
$f->increment($d, true);
$this->assertEquals('2011-03-15 10:59:00', $d->format('Y-m-d H:i:s'));
date_default_timezone_set($tz);
}
}

View File

@@ -0,0 +1,37 @@
<?php
namespace Cron\Tests;
use Cron\MinutesField;
use DateTime;
use PHPUnit_Framework_TestCase;
/**
* @author Michael Dowling <mtdowling@gmail.com>
*/
class MinutesFieldTest extends PHPUnit_Framework_TestCase
{
/**
* @covers Cron\MinutesField::validate
*/
public function testValidatesField()
{
$f = new MinutesField();
$this->assertTrue($f->validate('1'));
$this->assertTrue($f->validate('*'));
$this->assertTrue($f->validate('*/3,1,1-12'));
}
/**
* @covers Cron\MinutesField::increment
*/
public function testIncrementsDate()
{
$d = new DateTime('2011-03-15 11:15:00');
$f = new MinutesField();
$f->increment($d);
$this->assertEquals('2011-03-15 11:16:00', $d->format('Y-m-d H:i:s'));
$f->increment($d, true);
$this->assertEquals('2011-03-15 11:15:00', $d->format('Y-m-d H:i:s'));
}
}

View File

@@ -0,0 +1,81 @@
<?php
namespace Cron\Tests;
use Cron\MonthField;
use DateTime;
use PHPUnit_Framework_TestCase;
/**
* @author Michael Dowling <mtdowling@gmail.com>
*/
class MonthFieldTest extends PHPUnit_Framework_TestCase
{
/**
* @covers Cron\MonthField::validate
*/
public function testValidatesField()
{
$f = new MonthField();
$this->assertTrue($f->validate('12'));
$this->assertTrue($f->validate('*'));
$this->assertTrue($f->validate('*/10,2,1-12'));
$this->assertFalse($f->validate('1.fix-regexp'));
}
/**
* @covers Cron\MonthField::increment
*/
public function testIncrementsDate()
{
$d = new DateTime('2011-03-15 11:15:00');
$f = new MonthField();
$f->increment($d);
$this->assertEquals('2011-04-01 00:00:00', $d->format('Y-m-d H:i:s'));
$d = new DateTime('2011-03-15 11:15:00');
$f->increment($d, true);
$this->assertEquals('2011-02-28 23:59:00', $d->format('Y-m-d H:i:s'));
}
/**
* @covers Cron\MonthField::increment
*/
public function testIncrementsDateWithThirtyMinuteTimezone()
{
$tz = date_default_timezone_get();
date_default_timezone_set('America/St_Johns');
$d = new DateTime('2011-03-31 11:59:59');
$f = new MonthField();
$f->increment($d);
$this->assertEquals('2011-04-01 00:00:00', $d->format('Y-m-d H:i:s'));
$d = new DateTime('2011-03-15 11:15:00');
$f->increment($d, true);
$this->assertEquals('2011-02-28 23:59:00', $d->format('Y-m-d H:i:s'));
date_default_timezone_set($tz);
}
/**
* @covers Cron\MonthField::increment
*/
public function testIncrementsYearAsNeeded()
{
$f = new MonthField();
$d = new DateTime('2011-12-15 00:00:00');
$f->increment($d);
$this->assertEquals('2012-01-01 00:00:00', $d->format('Y-m-d H:i:s'));
}
/**
* @covers Cron\MonthField::increment
*/
public function testDecrementsYearAsNeeded()
{
$f = new MonthField();
$d = new DateTime('2011-01-15 00:00:00');
$f->increment($d, true);
$this->assertEquals('2010-12-31 23:59:00', $d->format('Y-m-d H:i:s'));
}
}

View File

@@ -0,0 +1,37 @@
<?php
namespace Cron\Tests;
use Cron\YearField;
use DateTime;
use PHPUnit_Framework_TestCase;
/**
* @author Michael Dowling <mtdowling@gmail.com>
*/
class YearFieldTest extends PHPUnit_Framework_TestCase
{
/**
* @covers Cron\YearField::validate
*/
public function testValidatesField()
{
$f = new YearField();
$this->assertTrue($f->validate('2011'));
$this->assertTrue($f->validate('*'));
$this->assertTrue($f->validate('*/10,2012,1-12'));
}
/**
* @covers Cron\YearField::increment
*/
public function testIncrementsDate()
{
$d = new DateTime('2011-03-15 11:15:00');
$f = new YearField();
$f->increment($d);
$this->assertEquals('2012-01-01 00:00:00', $d->format('Y-m-d H:i:s'));
$f->increment($d, true);
$this->assertEquals('2011-12-31 23:59:00', $d->format('Y-m-d H:i:s'));
}
}

19
vendor/mtdowling/jmespath.php/LICENSE vendored Normal file
View File

@@ -0,0 +1,19 @@
Copyright (c) 2014 Michael Dowling, https://github.com/mtdowling
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.

123
vendor/mtdowling/jmespath.php/README.rst vendored Normal file
View File

@@ -0,0 +1,123 @@
============
jmespath.php
============
JMESPath (pronounced "jaymz path") allows you to declaratively specify how to
extract elements from a JSON document. *jmespath.php* allows you to use
JMESPath in PHP applications with PHP data structures. It requires PHP 5.4 or
greater and can be installed through `Composer <http://getcomposer.org/doc/00-intro.md>`_
using the ``mtdowling/jmespath.php`` package.
.. code-block:: php
require 'vendor/autoload.php';
$expression = 'foo.*.baz';
$data = [
'foo' => [
'bar' => ['baz' => 1],
'bam' => ['baz' => 2],
'boo' => ['baz' => 3]
]
];
JmesPath\search($expression, $data);
// Returns: [1, 2, 3]
- `JMESPath Tutorial <http://jmespath.org/tutorial.html>`_
- `JMESPath Grammar <http://jmespath.org/specification.html#grammar>`_
- `JMESPath Python library <https://github.com/jmespath/jmespath.py>`_
PHP Usage
=========
The ``JmesPath\search`` function can be used in most cases when using the
library. This function utilizes a JMESPath runtime based on your environment.
The runtime utilized can be configured using environment variables and may at
some point in the future automatically utilize a C extension if available.
.. code-block:: php
$result = JmesPath\search($expression, $data);
// or, if you require PSR-4 compliance.
$result = JmesPath\Env::search($expression, $data);
Runtimes
--------
jmespath.php utilizes *runtimes*. There are currently two runtimes:
AstRuntime and CompilerRuntime.
AstRuntime is utilized by ``JmesPath\search()`` and ``JmesPath\Env::search()``
by default.
AstRuntime
~~~~~~~~~~
The AstRuntime will parse an expression, cache the resulting AST in memory,
and interpret the AST using an external tree visitor. AstRuntime provides a
good general approach for interpreting JMESPath expressions that have a low to
moderate level of reuse.
.. code-block:: php
$runtime = new JmesPath\AstRuntime();
$runtime('foo.bar', ['foo' => ['bar' => 'baz']]);
// > 'baz'
CompilerRuntime
~~~~~~~~~~~~~~~
``JmesPath\CompilerRuntime`` provides the most performance for
applications that have a moderate to high level of reuse of JMESPath
expressions. The CompilerRuntime will walk a JMESPath AST and emit PHP source
code, resulting in anywhere from 7x to 60x speed improvements.
Compiling JMESPath expressions to source code is a slower process than just
walking and interpreting a JMESPath AST (via the AstRuntime). However,
running the compiled JMESPath code results in much better performance than
walking an AST. This essentially means that there is a warm-up period when
using the ``CompilerRuntime``, but after the warm-up period, it will provide
much better performance.
Use the CompilerRuntime if you know that you will be executing JMESPath
expressions more than once or if you can pre-compile JMESPath expressions
before executing them (for example, server-side applications).
.. code-block:: php
// Note: The cache directory argument is optional.
$runtime = new JmesPath\CompilerRuntime('/path/to/compile/folder');
$runtime('foo.bar', ['foo' => ['bar' => 'baz']]);
// > 'baz'
Environment Variables
^^^^^^^^^^^^^^^^^^^^^
You can utilize the CompilerRuntime in ``JmesPath\search()`` by setting
the ``JP_PHP_COMPILE`` environment variable to "on" or to a directory
on disk used to store cached expressions.
Testing
=======
A comprehensive list of test cases can be found at
https://github.com/jmespath/jmespath.php/tree/master/tests/compliance.
These compliance tests are utilized by jmespath.php to ensure consistency with
other implementations, and can serve as examples of the language.
jmespath.php is tested using PHPUnit. In order to run the tests, you need to
first install the dependencies using Composer as described in the *Installation*
section. Next you just need to run the tests via make:
.. code-block:: bash
make test
You can run a suite of performance tests as well:
.. code-block:: bash
make perf

View File

@@ -0,0 +1,74 @@
#!/usr/bin/env php
<?php
if (file_exists(__DIR__ . '/../vendor/autoload.php')) {
require __DIR__ . '/../vendor/autoload.php';
} elseif (file_exists(__DIR__ . '/../../../autoload.php')) {
require __DIR__ . '/../../../autoload.php';
} elseif (file_exists(__DIR__ . '/../autoload.php')) {
require __DIR__ . '/../autoload.php';
} else {
throw new RuntimeException('Unable to locate autoload.php file.');
}
use JmesPath\Env;
use JmesPath\DebugRuntime;
$description = <<<EOT
Runs a JMESPath expression on the provided input or a test case.
Provide the JSON input and expression:
echo '{}' | jp.php expression
Or provide the path to a compliance script, a suite, and test case number:
jp.php --script path_to_script --suite test_suite_number --case test_case_number [expression]
EOT;
$args = [];
$currentKey = null;
for ($i = 1, $total = count($argv); $i < $total; $i++) {
if ($i % 2) {
if (substr($argv[$i], 0, 2) == '--') {
$currentKey = str_replace('--', '', $argv[$i]);
} else {
$currentKey = trim($argv[$i]);
}
} else {
$args[$currentKey] = $argv[$i];
$currentKey = null;
}
}
$expression = $currentKey;
if (isset($args['file']) || isset($args['suite']) || isset($args['case'])) {
if (!isset($args['file']) || !isset($args['suite']) || !isset($args['case'])) {
die($description);
}
// Manually run a compliance test
$path = realpath($args['file']);
file_exists($path) or die('File not found at ' . $path);
$json = json_decode(file_get_contents($path), true);
$set = $json[$args['suite']];
$data = $set['given'];
if (!isset($expression)) {
$expression = $set['cases'][$args['case']]['expression'];
echo "Expects\n=======\n";
if (isset($set['cases'][$args['case']]['result'])) {
echo json_encode($set['cases'][$args['case']]['result'], JSON_PRETTY_PRINT) . "\n\n";
} elseif (isset($set['cases'][$args['case']]['error'])) {
echo "{$set['cases'][$argv['case']]['error']} error\n\n";
} else {
echo "NULL\n\n";
}
}
} elseif (isset($expression)) {
// Pass in an expression and STDIN as a standalone argument
$data = json_decode(stream_get_contents(STDIN), true);
} else {
die($description);
}
$runtime = new DebugRuntime(Env::createRuntime());
$runtime($expression, $data);

View File

@@ -0,0 +1,68 @@
#!/usr/bin/env php
<?php
if (file_exists(__DIR__ . '/../vendor/autoload.php')) {
require __DIR__ . '/../vendor/autoload.php';
} elseif (file_exists(__DIR__ . '/../../../autoload.php')) {
require __DIR__ . '/../../../autoload.php';
} else {
throw new RuntimeException('Unable to locate autoload.php file.');
}
$xdebug = new \Composer\XdebugHandler\XdebugHandler('perf.php');
$xdebug->check();
unset($xdebug);
$dir = isset($argv[1]) ? $argv[1] : __DIR__ . '/../tests/compliance/perf';
is_dir($dir) or die('Dir not found: ' . $dir);
// Warm up the runner
\JmesPath\Env::search('foo', []);
$total = 0;
foreach (glob($dir . '/*.json') as $file) {
$total += runSuite($file);
}
echo "\nTotal time: {$total}\n";
function runSuite($file)
{
$contents = file_get_contents($file);
$json = json_decode($contents, true);
$total = 0;
foreach ($json as $suite) {
foreach ($suite['cases'] as $case) {
$total += runCase(
$suite['given'],
$case['expression'],
$case['name']
);
}
}
return $total;
}
function runCase($given, $expression, $name)
{
$best = 99999;
$runtime = \JmesPath\Env::createRuntime();
for ($i = 0; $i < 100; $i++) {
$t = microtime(true);
$runtime($expression, $given);
$tryTime = (microtime(true) - $t) * 1000;
if ($tryTime < $best) {
$best = $tryTime;
}
if (!getenv('CACHE')) {
$runtime = \JmesPath\Env::createRuntime();
// Delete compiled scripts if not caching.
if ($runtime instanceof \JmesPath\CompilerRuntime) {
array_map('unlink', glob(sys_get_temp_dir() . '/jmespath_*.php'));
}
}
}
printf("time: %07.4fms name: %s\n", $best, $name);
return $best;
}

View File

@@ -0,0 +1,39 @@
{
"name": "mtdowling/jmespath.php",
"description": "Declaratively specify how to extract elements from a JSON document",
"keywords": ["json", "jsonpath"],
"license": "MIT",
"authors": [
{
"name": "Michael Dowling",
"email": "mtdowling@gmail.com",
"homepage": "https://github.com/mtdowling"
}
],
"require": {
"php": "^5.4 || ^7.0 || ^8.0",
"symfony/polyfill-mbstring": "^1.17"
},
"require-dev": {
"composer/xdebug-handler": "^1.4",
"phpunit/phpunit": "^4.8.36 || ^7.5.15"
},
"autoload": {
"psr-4": {
"JmesPath\\": "src/"
},
"files": ["src/JmesPath.php"]
},
"bin": ["bin/jp.php"],
"extra": {
"branch-alias": {
"dev-master": "2.6-dev"
}
}
}

View File

@@ -0,0 +1,47 @@
<?php
namespace JmesPath;
/**
* Uses an external tree visitor to interpret an AST.
*/
class AstRuntime
{
private $parser;
private $interpreter;
private $cache = [];
private $cachedCount = 0;
public function __construct(
Parser $parser = null,
callable $fnDispatcher = null
) {
$fnDispatcher = $fnDispatcher ?: FnDispatcher::getInstance();
$this->interpreter = new TreeInterpreter($fnDispatcher);
$this->parser = $parser ?: new Parser();
}
/**
* Returns data from the provided input that matches a given JMESPath
* expression.
*
* @param string $expression JMESPath expression to evaluate
* @param mixed $data Data to search. This data should be data that
* is similar to data returned from json_decode
* using associative arrays rather than objects.
*
* @return mixed Returns the matching data or null
*/
public function __invoke($expression, $data)
{
if (!isset($this->cache[$expression])) {
// Clear the AST cache when it hits 1024 entries
if (++$this->cachedCount > 1024) {
$this->cache = [];
$this->cachedCount = 0;
}
$this->cache[$expression] = $this->parser->parse($expression);
}
return $this->interpreter->visit($this->cache[$expression], $data);
}
}

View File

@@ -0,0 +1,83 @@
<?php
namespace JmesPath;
/**
* Compiles JMESPath expressions to PHP source code and executes it.
*
* JMESPath file names are stored in the cache directory using the following
* logic to determine the filename:
*
* 1. Start with the string "jmespath_"
* 2. Append the MD5 checksum of the expression.
* 3. Append ".php"
*/
class CompilerRuntime
{
private $parser;
private $compiler;
private $cacheDir;
private $interpreter;
/**
* @param string|null $dir Directory used to store compiled PHP files.
* @param Parser|null $parser JMESPath parser to utilize
* @throws \RuntimeException if the cache directory cannot be created
*/
public function __construct($dir = null, Parser $parser = null)
{
$this->parser = $parser ?: new Parser();
$this->compiler = new TreeCompiler();
$dir = $dir ?: sys_get_temp_dir();
if (!is_dir($dir) && !mkdir($dir, 0755, true)) {
throw new \RuntimeException("Unable to create cache directory: $dir");
}
$this->cacheDir = realpath($dir);
$this->interpreter = new TreeInterpreter();
}
/**
* Returns data from the provided input that matches a given JMESPath
* expression.
*
* @param string $expression JMESPath expression to evaluate
* @param mixed $data Data to search. This data should be data that
* is similar to data returned from json_decode
* using associative arrays rather than objects.
*
* @return mixed Returns the matching data or null
* @throws \RuntimeException
*/
public function __invoke($expression, $data)
{
$functionName = 'jmespath_' . md5($expression);
if (!function_exists($functionName)) {
$filename = "{$this->cacheDir}/{$functionName}.php";
if (!file_exists($filename)) {
$this->compile($filename, $expression, $functionName);
}
require $filename;
}
return $functionName($this->interpreter, $data);
}
private function compile($filename, $expression, $functionName)
{
$code = $this->compiler->visit(
$this->parser->parse($expression),
$functionName,
$expression
);
if (!file_put_contents($filename, $code)) {
throw new \RuntimeException(sprintf(
'Unable to write the compiled PHP code to: %s (%s)',
$filename,
var_export(error_get_last(), true)
));
}
}
}

View File

@@ -0,0 +1,109 @@
<?php
namespace JmesPath;
/**
* Provides CLI debugging information for the AST and Compiler runtimes.
*/
class DebugRuntime
{
private $runtime;
private $out;
private $lexer;
private $parser;
public function __construct(callable $runtime, $output = null)
{
$this->runtime = $runtime;
$this->out = $output ?: STDOUT;
$this->lexer = new Lexer();
$this->parser = new Parser($this->lexer);
}
public function __invoke($expression, $data)
{
if ($this->runtime instanceof CompilerRuntime) {
return $this->debugCompiled($expression, $data);
}
return $this->debugInterpreted($expression, $data);
}
private function debugInterpreted($expression, $data)
{
return $this->debugCallback(
function () use ($expression, $data) {
$runtime = $this->runtime;
return $runtime($expression, $data);
},
$expression,
$data
);
}
private function debugCompiled($expression, $data)
{
$result = $this->debugCallback(
function () use ($expression, $data) {
$runtime = $this->runtime;
return $runtime($expression, $data);
},
$expression,
$data
);
$this->dumpCompiledCode($expression);
return $result;
}
private function dumpTokens($expression)
{
$lexer = new Lexer();
fwrite($this->out, "Tokens\n======\n\n");
$tokens = $lexer->tokenize($expression);
foreach ($tokens as $t) {
fprintf(
$this->out,
"%3d %-13s %s\n", $t['pos'], $t['type'],
json_encode($t['value'])
);
}
fwrite($this->out, "\n");
}
private function dumpAst($expression)
{
$parser = new Parser();
$ast = $parser->parse($expression);
fwrite($this->out, "AST\n========\n\n");
fwrite($this->out, json_encode($ast, JSON_PRETTY_PRINT) . "\n");
}
private function dumpCompiledCode($expression)
{
fwrite($this->out, "Code\n========\n\n");
$dir = sys_get_temp_dir();
$hash = md5($expression);
$functionName = "jmespath_{$hash}";
$filename = "{$dir}/{$functionName}.php";
fwrite($this->out, "File: {$filename}\n\n");
fprintf($this->out, file_get_contents($filename));
}
private function debugCallback(callable $debugFn, $expression, $data)
{
fprintf($this->out, "Expression\n==========\n\n%s\n\n", $expression);
$this->dumpTokens($expression);
$this->dumpAst($expression);
fprintf($this->out, "\nData\n====\n\n%s\n\n", json_encode($data, JSON_PRETTY_PRINT));
$startTime = microtime(true);
$result = $debugFn();
$total = microtime(true) - $startTime;
fprintf($this->out, "\nResult\n======\n\n%s\n\n", json_encode($result, JSON_PRETTY_PRINT));
fwrite($this->out, "Time\n====\n\n");
fprintf($this->out, "Total time: %f ms\n\n", $total);
return $result;
}
}

View File

@@ -0,0 +1,91 @@
<?php
namespace JmesPath;
/**
* Provides a simple environment based search.
*
* The runtime utilized by the Env class can be customized via environment
* variables. If the JP_PHP_COMPILE environment variable is specified, then the
* CompilerRuntime will be utilized. If set to "on", JMESPath expressions will
* be cached to the system's temp directory. Set the environment variable to
* a string to cache expressions to a specific directory.
*/
final class Env
{
const COMPILE_DIR = 'JP_PHP_COMPILE';
/**
* Returns data from the input array that matches a JMESPath expression.
*
* @param string $expression JMESPath expression to evaluate
* @param mixed $data JSON-like data to search
*
* @return mixed Returns the matching data or null
*/
public static function search($expression, $data)
{
static $runtime;
if (!$runtime) {
$runtime = Env::createRuntime();
}
return $runtime($expression, $data);
}
/**
* Creates a JMESPath runtime based on environment variables and extensions
* available on a system.
*
* @return callable
*/
public static function createRuntime()
{
switch ($compileDir = self::getEnvVariable(self::COMPILE_DIR)) {
case false: return new AstRuntime();
case 'on': return new CompilerRuntime();
default: return new CompilerRuntime($compileDir);
}
}
/**
* Delete all previously compiled JMESPath files from the JP_COMPILE_DIR
* directory or sys_get_temp_dir().
*
* @return int Returns the number of deleted files.
*/
public static function cleanCompileDir()
{
$total = 0;
$compileDir = self::getEnvVariable(self::COMPILE_DIR) ?: sys_get_temp_dir();
foreach (glob("{$compileDir}/jmespath_*.php") as $file) {
$total++;
unlink($file);
}
return $total;
}
/**
* Reads an environment variable from $_SERVER, $_ENV or via getenv().
*
* @param string $name
*
* @return string|null
*/
private static function getEnvVariable($name)
{
if (array_key_exists($name, $_SERVER)) {
return $_SERVER[$name];
}
if (array_key_exists($name, $_ENV)) {
return $_ENV[$name];
}
$value = getenv($name);
return $value === false ? null : $value;
}
}

View File

@@ -0,0 +1,407 @@
<?php
namespace JmesPath;
/**
* Dispatches to named JMESPath functions using a single function that has the
* following signature:
*
* mixed $result = fn(string $function_name, array $args)
*/
class FnDispatcher
{
/**
* Gets a cached instance of the default function implementations.
*
* @return FnDispatcher
*/
public static function getInstance()
{
static $instance = null;
if (!$instance) {
$instance = new self();
}
return $instance;
}
/**
* @param string $fn Function name.
* @param array $args Function arguments.
*
* @return mixed
*/
public function __invoke($fn, array $args)
{
return $this->{'fn_' . $fn}($args);
}
private function fn_abs(array $args)
{
$this->validate('abs', $args, [['number']]);
return abs($args[0]);
}
private function fn_avg(array $args)
{
$this->validate('avg', $args, [['array']]);
$sum = $this->reduce('avg:0', $args[0], ['number'], function ($a, $b) {
return Utils::add($a, $b);
});
return $args[0] ? ($sum / count($args[0])) : null;
}
private function fn_ceil(array $args)
{
$this->validate('ceil', $args, [['number']]);
return ceil($args[0]);
}
private function fn_contains(array $args)
{
$this->validate('contains', $args, [['string', 'array'], ['any']]);
if (is_array($args[0])) {
return in_array($args[1], $args[0]);
} elseif (is_string($args[1])) {
return mb_strpos($args[0], $args[1], 0, 'UTF-8') !== false;
} else {
return null;
}
}
private function fn_ends_with(array $args)
{
$this->validate('ends_with', $args, [['string'], ['string']]);
list($search, $suffix) = $args;
return $suffix === '' || mb_substr($search, -mb_strlen($suffix, 'UTF-8'), null, 'UTF-8') === $suffix;
}
private function fn_floor(array $args)
{
$this->validate('floor', $args, [['number']]);
return floor($args[0]);
}
private function fn_not_null(array $args)
{
if (!$args) {
throw new \RuntimeException(
"not_null() expects 1 or more arguments, 0 were provided"
);
}
return array_reduce($args, function ($carry, $item) {
return $carry !== null ? $carry : $item;
});
}
private function fn_join(array $args)
{
$this->validate('join', $args, [['string'], ['array']]);
$fn = function ($a, $b, $i) use ($args) {
return $i ? ($a . $args[0] . $b) : $b;
};
return $this->reduce('join:0', $args[1], ['string'], $fn);
}
private function fn_keys(array $args)
{
$this->validate('keys', $args, [['object']]);
return array_keys((array) $args[0]);
}
private function fn_length(array $args)
{
$this->validate('length', $args, [['string', 'array', 'object']]);
return is_string($args[0]) ? mb_strlen($args[0], 'UTF-8') : count((array) $args[0]);
}
private function fn_max(array $args)
{
$this->validate('max', $args, [['array']]);
$fn = function ($a, $b) {
return $a >= $b ? $a : $b;
};
return $this->reduce('max:0', $args[0], ['number', 'string'], $fn);
}
private function fn_max_by(array $args)
{
$this->validate('max_by', $args, [['array'], ['expression']]);
$expr = $this->wrapExpression('max_by:1', $args[1], ['number', 'string']);
$fn = function ($carry, $item, $index) use ($expr) {
return $index
? ($expr($carry) >= $expr($item) ? $carry : $item)
: $item;
};
return $this->reduce('max_by:1', $args[0], ['any'], $fn);
}
private function fn_min(array $args)
{
$this->validate('min', $args, [['array']]);
$fn = function ($a, $b, $i) {
return $i && $a <= $b ? $a : $b;
};
return $this->reduce('min:0', $args[0], ['number', 'string'], $fn);
}
private function fn_min_by(array $args)
{
$this->validate('min_by', $args, [['array'], ['expression']]);
$expr = $this->wrapExpression('min_by:1', $args[1], ['number', 'string']);
$i = -1;
$fn = function ($a, $b) use ($expr, &$i) {
return ++$i ? ($expr($a) <= $expr($b) ? $a : $b) : $b;
};
return $this->reduce('min_by:1', $args[0], ['any'], $fn);
}
private function fn_reverse(array $args)
{
$this->validate('reverse', $args, [['array', 'string']]);
if (is_array($args[0])) {
return array_reverse($args[0]);
} elseif (is_string($args[0])) {
return strrev($args[0]);
} else {
throw new \RuntimeException('Cannot reverse provided argument');
}
}
private function fn_sum(array $args)
{
$this->validate('sum', $args, [['array']]);
$fn = function ($a, $b) {
return Utils::add($a, $b);
};
return $this->reduce('sum:0', $args[0], ['number'], $fn);
}
private function fn_sort(array $args)
{
$this->validate('sort', $args, [['array']]);
$valid = ['string', 'number'];
return Utils::stableSort($args[0], function ($a, $b) use ($valid) {
$this->validateSeq('sort:0', $valid, $a, $b);
return strnatcmp($a, $b);
});
}
private function fn_sort_by(array $args)
{
$this->validate('sort_by', $args, [['array'], ['expression']]);
$expr = $args[1];
$valid = ['string', 'number'];
return Utils::stableSort(
$args[0],
function ($a, $b) use ($expr, $valid) {
$va = $expr($a);
$vb = $expr($b);
$this->validateSeq('sort_by:0', $valid, $va, $vb);
return strnatcmp($va, $vb);
}
);
}
private function fn_starts_with(array $args)
{
$this->validate('starts_with', $args, [['string'], ['string']]);
list($search, $prefix) = $args;
return $prefix === '' || mb_strpos($search, $prefix, 0, 'UTF-8') === 0;
}
private function fn_type(array $args)
{
$this->validateArity('type', count($args), 1);
return Utils::type($args[0]);
}
private function fn_to_string(array $args)
{
$this->validateArity('to_string', count($args), 1);
$v = $args[0];
if (is_string($v)) {
return $v;
} elseif (is_object($v)
&& !($v instanceof \JsonSerializable)
&& method_exists($v, '__toString')
) {
return (string) $v;
}
return json_encode($v);
}
private function fn_to_number(array $args)
{
$this->validateArity('to_number', count($args), 1);
$value = $args[0];
$type = Utils::type($value);
if ($type == 'number') {
return $value;
} elseif ($type == 'string' && is_numeric($value)) {
return mb_strpos($value, '.', 0, 'UTF-8') ? (float) $value : (int) $value;
} else {
return null;
}
}
private function fn_values(array $args)
{
$this->validate('values', $args, [['array', 'object']]);
return array_values((array) $args[0]);
}
private function fn_merge(array $args)
{
if (!$args) {
throw new \RuntimeException(
"merge() expects 1 or more arguments, 0 were provided"
);
}
return call_user_func_array('array_replace', $args);
}
private function fn_to_array(array $args)
{
$this->validate('to_array', $args, [['any']]);
return Utils::isArray($args[0]) ? $args[0] : [$args[0]];
}
private function fn_map(array $args)
{
$this->validate('map', $args, [['expression'], ['any']]);
$result = [];
foreach ($args[1] as $a) {
$result[] = $args[0]($a);
}
return $result;
}
private function typeError($from, $msg)
{
if (mb_strpos($from, ':', 0, 'UTF-8')) {
list($fn, $pos) = explode(':', $from);
throw new \RuntimeException(
sprintf('Argument %d of %s %s', $pos, $fn, $msg)
);
} else {
throw new \RuntimeException(
sprintf('Type error: %s %s', $from, $msg)
);
}
}
private function validateArity($from, $given, $expected)
{
if ($given != $expected) {
$err = "%s() expects {$expected} arguments, {$given} were provided";
throw new \RuntimeException(sprintf($err, $from));
}
}
private function validate($from, $args, $types = [])
{
$this->validateArity($from, count($args), count($types));
foreach ($args as $index => $value) {
if (!isset($types[$index]) || !$types[$index]) {
continue;
}
$this->validateType("{$from}:{$index}", $value, $types[$index]);
}
}
private function validateType($from, $value, array $types)
{
if ($types[0] == 'any'
|| in_array(Utils::type($value), $types)
|| ($value === [] && in_array('object', $types))
) {
return;
}
$msg = 'must be one of the following types: ' . implode(', ', $types)
. '. ' . Utils::type($value) . ' found';
$this->typeError($from, $msg);
}
/**
* Validates value A and B, ensures they both are correctly typed, and of
* the same type.
*
* @param string $from String of function:argument_position
* @param array $types Array of valid value types.
* @param mixed $a Value A
* @param mixed $b Value B
*/
private function validateSeq($from, array $types, $a, $b)
{
$ta = Utils::type($a);
$tb = Utils::type($b);
if ($ta !== $tb) {
$msg = "encountered a type mismatch in sequence: {$ta}, {$tb}";
$this->typeError($from, $msg);
}
$typeMatch = ($types && $types[0] == 'any') || in_array($ta, $types);
if (!$typeMatch) {
$msg = 'encountered a type error in sequence. The argument must be '
. 'an array of ' . implode('|', $types) . ' types. '
. "Found {$ta}, {$tb}.";
$this->typeError($from, $msg);
}
}
/**
* Reduces and validates an array of values to a single value using a fn.
*
* @param string $from String of function:argument_position
* @param array $values Values to reduce.
* @param array $types Array of valid value types.
* @param callable $reduce Reduce function that accepts ($carry, $item).
*
* @return mixed
*/
private function reduce($from, array $values, array $types, callable $reduce)
{
$i = -1;
return array_reduce(
$values,
function ($carry, $item) use ($from, $types, $reduce, &$i) {
if (++$i > 0) {
$this->validateSeq($from, $types, $carry, $item);
}
return $reduce($carry, $item, $i);
}
);
}
/**
* Validates the return values of expressions as they are applied.
*
* @param string $from Function name : position
* @param callable $expr Expression function to validate.
* @param array $types Array of acceptable return type values.
*
* @return callable Returns a wrapped function
*/
private function wrapExpression($from, callable $expr, array $types)
{
list($fn, $pos) = explode(':', $from);
$from = "The expression return value of argument {$pos} of {$fn}";
return function ($value) use ($from, $expr, $types) {
$value = $expr($value);
$this->validateType($from, $value, $types);
return $value;
};
}
/** @internal Pass function name validation off to runtime */
public function __call($name, $args)
{
$name = str_replace('fn_', '', $name);
throw new \RuntimeException("Call to undefined function {$name}");
}
}

View File

@@ -0,0 +1,17 @@
<?php
namespace JmesPath;
/**
* Returns data from the input array that matches a JMESPath expression.
*
* @param string $expression Expression to search.
* @param mixed $data Data to search.
*
* @return mixed
*/
if (!function_exists(__NAMESPACE__ . '\search')) {
function search($expression, $data)
{
return Env::search($expression, $data);
}
}

View File

@@ -0,0 +1,444 @@
<?php
namespace JmesPath;
/**
* Tokenizes JMESPath expressions
*/
class Lexer
{
const T_DOT = 'dot';
const T_STAR = 'star';
const T_COMMA = 'comma';
const T_COLON = 'colon';
const T_CURRENT = 'current';
const T_EXPREF = 'expref';
const T_LPAREN = 'lparen';
const T_RPAREN = 'rparen';
const T_LBRACE = 'lbrace';
const T_RBRACE = 'rbrace';
const T_LBRACKET = 'lbracket';
const T_RBRACKET = 'rbracket';
const T_FLATTEN = 'flatten';
const T_IDENTIFIER = 'identifier';
const T_NUMBER = 'number';
const T_QUOTED_IDENTIFIER = 'quoted_identifier';
const T_UNKNOWN = 'unknown';
const T_PIPE = 'pipe';
const T_OR = 'or';
const T_AND = 'and';
const T_NOT = 'not';
const T_FILTER = 'filter';
const T_LITERAL = 'literal';
const T_EOF = 'eof';
const T_COMPARATOR = 'comparator';
const STATE_IDENTIFIER = 0;
const STATE_NUMBER = 1;
const STATE_SINGLE_CHAR = 2;
const STATE_WHITESPACE = 3;
const STATE_STRING_LITERAL = 4;
const STATE_QUOTED_STRING = 5;
const STATE_JSON_LITERAL = 6;
const STATE_LBRACKET = 7;
const STATE_PIPE = 8;
const STATE_LT = 9;
const STATE_GT = 10;
const STATE_EQ = 11;
const STATE_NOT = 12;
const STATE_AND = 13;
/** @var array We know what token we are consuming based on each char */
private static $transitionTable = [
'<' => self::STATE_LT,
'>' => self::STATE_GT,
'=' => self::STATE_EQ,
'!' => self::STATE_NOT,
'[' => self::STATE_LBRACKET,
'|' => self::STATE_PIPE,
'&' => self::STATE_AND,
'`' => self::STATE_JSON_LITERAL,
'"' => self::STATE_QUOTED_STRING,
"'" => self::STATE_STRING_LITERAL,
'-' => self::STATE_NUMBER,
'0' => self::STATE_NUMBER,
'1' => self::STATE_NUMBER,
'2' => self::STATE_NUMBER,
'3' => self::STATE_NUMBER,
'4' => self::STATE_NUMBER,
'5' => self::STATE_NUMBER,
'6' => self::STATE_NUMBER,
'7' => self::STATE_NUMBER,
'8' => self::STATE_NUMBER,
'9' => self::STATE_NUMBER,
' ' => self::STATE_WHITESPACE,
"\t" => self::STATE_WHITESPACE,
"\n" => self::STATE_WHITESPACE,
"\r" => self::STATE_WHITESPACE,
'.' => self::STATE_SINGLE_CHAR,
'*' => self::STATE_SINGLE_CHAR,
']' => self::STATE_SINGLE_CHAR,
',' => self::STATE_SINGLE_CHAR,
':' => self::STATE_SINGLE_CHAR,
'@' => self::STATE_SINGLE_CHAR,
'(' => self::STATE_SINGLE_CHAR,
')' => self::STATE_SINGLE_CHAR,
'{' => self::STATE_SINGLE_CHAR,
'}' => self::STATE_SINGLE_CHAR,
'_' => self::STATE_IDENTIFIER,
'A' => self::STATE_IDENTIFIER,
'B' => self::STATE_IDENTIFIER,
'C' => self::STATE_IDENTIFIER,
'D' => self::STATE_IDENTIFIER,
'E' => self::STATE_IDENTIFIER,
'F' => self::STATE_IDENTIFIER,
'G' => self::STATE_IDENTIFIER,
'H' => self::STATE_IDENTIFIER,
'I' => self::STATE_IDENTIFIER,
'J' => self::STATE_IDENTIFIER,
'K' => self::STATE_IDENTIFIER,
'L' => self::STATE_IDENTIFIER,
'M' => self::STATE_IDENTIFIER,
'N' => self::STATE_IDENTIFIER,
'O' => self::STATE_IDENTIFIER,
'P' => self::STATE_IDENTIFIER,
'Q' => self::STATE_IDENTIFIER,
'R' => self::STATE_IDENTIFIER,
'S' => self::STATE_IDENTIFIER,
'T' => self::STATE_IDENTIFIER,
'U' => self::STATE_IDENTIFIER,
'V' => self::STATE_IDENTIFIER,
'W' => self::STATE_IDENTIFIER,
'X' => self::STATE_IDENTIFIER,
'Y' => self::STATE_IDENTIFIER,
'Z' => self::STATE_IDENTIFIER,
'a' => self::STATE_IDENTIFIER,
'b' => self::STATE_IDENTIFIER,
'c' => self::STATE_IDENTIFIER,
'd' => self::STATE_IDENTIFIER,
'e' => self::STATE_IDENTIFIER,
'f' => self::STATE_IDENTIFIER,
'g' => self::STATE_IDENTIFIER,
'h' => self::STATE_IDENTIFIER,
'i' => self::STATE_IDENTIFIER,
'j' => self::STATE_IDENTIFIER,
'k' => self::STATE_IDENTIFIER,
'l' => self::STATE_IDENTIFIER,
'm' => self::STATE_IDENTIFIER,
'n' => self::STATE_IDENTIFIER,
'o' => self::STATE_IDENTIFIER,
'p' => self::STATE_IDENTIFIER,
'q' => self::STATE_IDENTIFIER,
'r' => self::STATE_IDENTIFIER,
's' => self::STATE_IDENTIFIER,
't' => self::STATE_IDENTIFIER,
'u' => self::STATE_IDENTIFIER,
'v' => self::STATE_IDENTIFIER,
'w' => self::STATE_IDENTIFIER,
'x' => self::STATE_IDENTIFIER,
'y' => self::STATE_IDENTIFIER,
'z' => self::STATE_IDENTIFIER,
];
/** @var array Valid identifier characters after first character */
private $validIdentifier = [
'A' => true, 'B' => true, 'C' => true, 'D' => true, 'E' => true,
'F' => true, 'G' => true, 'H' => true, 'I' => true, 'J' => true,
'K' => true, 'L' => true, 'M' => true, 'N' => true, 'O' => true,
'P' => true, 'Q' => true, 'R' => true, 'S' => true, 'T' => true,
'U' => true, 'V' => true, 'W' => true, 'X' => true, 'Y' => true,
'Z' => true, 'a' => true, 'b' => true, 'c' => true, 'd' => true,
'e' => true, 'f' => true, 'g' => true, 'h' => true, 'i' => true,
'j' => true, 'k' => true, 'l' => true, 'm' => true, 'n' => true,
'o' => true, 'p' => true, 'q' => true, 'r' => true, 's' => true,
't' => true, 'u' => true, 'v' => true, 'w' => true, 'x' => true,
'y' => true, 'z' => true, '_' => true, '0' => true, '1' => true,
'2' => true, '3' => true, '4' => true, '5' => true, '6' => true,
'7' => true, '8' => true, '9' => true,
];
/** @var array Valid number characters after the first character */
private $numbers = [
'0' => true, '1' => true, '2' => true, '3' => true, '4' => true,
'5' => true, '6' => true, '7' => true, '8' => true, '9' => true
];
/** @var array Map of simple single character tokens */
private $simpleTokens = [
'.' => self::T_DOT,
'*' => self::T_STAR,
']' => self::T_RBRACKET,
',' => self::T_COMMA,
':' => self::T_COLON,
'@' => self::T_CURRENT,
'(' => self::T_LPAREN,
')' => self::T_RPAREN,
'{' => self::T_LBRACE,
'}' => self::T_RBRACE,
];
/**
* Tokenize the JMESPath expression into an array of tokens hashes that
* contain a 'type', 'value', and 'key'.
*
* @param string $input JMESPath input
*
* @return array
* @throws SyntaxErrorException
*/
public function tokenize($input)
{
$tokens = [];
if ($input === '') {
goto eof;
}
$chars = str_split($input);
while (false !== ($current = current($chars))) {
// Every character must be in the transition character table.
if (!isset(self::$transitionTable[$current])) {
$tokens[] = [
'type' => self::T_UNKNOWN,
'pos' => key($chars),
'value' => $current
];
next($chars);
continue;
}
$state = self::$transitionTable[$current];
if ($state === self::STATE_SINGLE_CHAR) {
// Consume simple tokens like ".", ",", "@", etc.
$tokens[] = [
'type' => $this->simpleTokens[$current],
'pos' => key($chars),
'value' => $current
];
next($chars);
} elseif ($state === self::STATE_IDENTIFIER) {
// Consume identifiers
$start = key($chars);
$buffer = '';
do {
$buffer .= $current;
$current = next($chars);
} while ($current !== false && isset($this->validIdentifier[$current]));
$tokens[] = [
'type' => self::T_IDENTIFIER,
'value' => $buffer,
'pos' => $start
];
} elseif ($state === self::STATE_WHITESPACE) {
// Skip whitespace
next($chars);
} elseif ($state === self::STATE_LBRACKET) {
// Consume "[", "[?", and "[]"
$position = key($chars);
$actual = next($chars);
if ($actual === ']') {
next($chars);
$tokens[] = [
'type' => self::T_FLATTEN,
'pos' => $position,
'value' => '[]'
];
} elseif ($actual === '?') {
next($chars);
$tokens[] = [
'type' => self::T_FILTER,
'pos' => $position,
'value' => '[?'
];
} else {
$tokens[] = [
'type' => self::T_LBRACKET,
'pos' => $position,
'value' => '['
];
}
} elseif ($state === self::STATE_STRING_LITERAL) {
// Consume raw string literals
$t = $this->inside($chars, "'", self::T_LITERAL);
$t['value'] = str_replace("\\'", "'", $t['value']);
$tokens[] = $t;
} elseif ($state === self::STATE_PIPE) {
// Consume pipe and OR
$tokens[] = $this->matchOr($chars, '|', '|', self::T_OR, self::T_PIPE);
} elseif ($state == self::STATE_JSON_LITERAL) {
// Consume JSON literals
$token = $this->inside($chars, '`', self::T_LITERAL);
if ($token['type'] === self::T_LITERAL) {
$token['value'] = str_replace('\\`', '`', $token['value']);
$token = $this->parseJson($token);
}
$tokens[] = $token;
} elseif ($state == self::STATE_NUMBER) {
// Consume numbers
$start = key($chars);
$buffer = '';
do {
$buffer .= $current;
$current = next($chars);
} while ($current !== false && isset($this->numbers[$current]));
$tokens[] = [
'type' => self::T_NUMBER,
'value' => (int)$buffer,
'pos' => $start
];
} elseif ($state === self::STATE_QUOTED_STRING) {
// Consume quoted identifiers
$token = $this->inside($chars, '"', self::T_QUOTED_IDENTIFIER);
if ($token['type'] === self::T_QUOTED_IDENTIFIER) {
$token['value'] = '"' . $token['value'] . '"';
$token = $this->parseJson($token);
}
$tokens[] = $token;
} elseif ($state === self::STATE_EQ) {
// Consume equals
$tokens[] = $this->matchOr($chars, '=', '=', self::T_COMPARATOR, self::T_UNKNOWN);
} elseif ($state == self::STATE_AND) {
$tokens[] = $this->matchOr($chars, '&', '&', self::T_AND, self::T_EXPREF);
} elseif ($state === self::STATE_NOT) {
// Consume not equal
$tokens[] = $this->matchOr($chars, '!', '=', self::T_COMPARATOR, self::T_NOT);
} else {
// either '<' or '>'
// Consume less than and greater than
$tokens[] = $this->matchOr($chars, $current, '=', self::T_COMPARATOR, self::T_COMPARATOR);
}
}
eof:
$tokens[] = [
'type' => self::T_EOF,
'pos' => mb_strlen($input, 'UTF-8'),
'value' => null
];
return $tokens;
}
/**
* Returns a token based on whether or not the next token matches the
* expected value. If it does, a token of "$type" is returned. Otherwise,
* a token of "$orElse" type is returned.
*
* @param array $chars Array of characters by reference.
* @param string $current The current character.
* @param string $expected Expected character.
* @param string $type Expected result type.
* @param string $orElse Otherwise return a token of this type.
*
* @return array Returns a conditional token.
*/
private function matchOr(array &$chars, $current, $expected, $type, $orElse)
{
if (next($chars) === $expected) {
next($chars);
return [
'type' => $type,
'pos' => key($chars) - 1,
'value' => $current . $expected
];
}
return [
'type' => $orElse,
'pos' => key($chars) - 1,
'value' => $current
];
}
/**
* Returns a token the is the result of consuming inside of delimiter
* characters. Escaped delimiters will be adjusted before returning a
* value. If the token is not closed, "unknown" is returned.
*
* @param array $chars Array of characters by reference.
* @param string $delim The delimiter character.
* @param string $type Token type.
*
* @return array Returns the consumed token.
*/
private function inside(array &$chars, $delim, $type)
{
$position = key($chars);
$current = next($chars);
$buffer = '';
while ($current !== $delim) {
if ($current === '\\') {
$buffer .= '\\';
$current = next($chars);
}
if ($current === false) {
// Unclosed delimiter
return [
'type' => self::T_UNKNOWN,
'value' => $buffer,
'pos' => $position
];
}
$buffer .= $current;
$current = next($chars);
}
next($chars);
return ['type' => $type, 'value' => $buffer, 'pos' => $position];
}
/**
* Parses a JSON token or sets the token type to "unknown" on error.
*
* @param array $token Token that needs parsing.
*
* @return array Returns a token with a parsed value.
*/
private function parseJson(array $token)
{
$value = json_decode($token['value'], true);
if ($error = json_last_error()) {
// Legacy support for elided quotes. Try to parse again by adding
// quotes around the bad input value.
$value = json_decode('"' . $token['value'] . '"', true);
if ($error = json_last_error()) {
$token['type'] = self::T_UNKNOWN;
return $token;
}
}
$token['value'] = $value;
return $token;
}
}

View File

@@ -0,0 +1,519 @@
<?php
namespace JmesPath;
use JmesPath\Lexer as T;
/**
* JMESPath Pratt parser
* @link http://hall.org.ua/halls/wizzard/pdf/Vaughan.Pratt.TDOP.pdf
*/
class Parser
{
/** @var Lexer */
private $lexer;
private $tokens;
private $token;
private $tpos;
private $expression;
private static $nullToken = ['type' => T::T_EOF];
private static $currentNode = ['type' => T::T_CURRENT];
private static $bp = [
T::T_EOF => 0,
T::T_QUOTED_IDENTIFIER => 0,
T::T_IDENTIFIER => 0,
T::T_RBRACKET => 0,
T::T_RPAREN => 0,
T::T_COMMA => 0,
T::T_RBRACE => 0,
T::T_NUMBER => 0,
T::T_CURRENT => 0,
T::T_EXPREF => 0,
T::T_COLON => 0,
T::T_PIPE => 1,
T::T_OR => 2,
T::T_AND => 3,
T::T_COMPARATOR => 5,
T::T_FLATTEN => 9,
T::T_STAR => 20,
T::T_FILTER => 21,
T::T_DOT => 40,
T::T_NOT => 45,
T::T_LBRACE => 50,
T::T_LBRACKET => 55,
T::T_LPAREN => 60,
];
/** @var array Acceptable tokens after a dot token */
private static $afterDot = [
T::T_IDENTIFIER => true, // foo.bar
T::T_QUOTED_IDENTIFIER => true, // foo."bar"
T::T_STAR => true, // foo.*
T::T_LBRACE => true, // foo[1]
T::T_LBRACKET => true, // foo{a: 0}
T::T_FILTER => true, // foo.[?bar==10]
];
/**
* @param Lexer|null $lexer Lexer used to tokenize expressions
*/
public function __construct(Lexer $lexer = null)
{
$this->lexer = $lexer ?: new Lexer();
}
/**
* Parses a JMESPath expression into an AST
*
* @param string $expression JMESPath expression to compile
*
* @return array Returns an array based AST
* @throws SyntaxErrorException
*/
public function parse($expression)
{
$this->expression = $expression;
$this->tokens = $this->lexer->tokenize($expression);
$this->tpos = -1;
$this->next();
$result = $this->expr();
if ($this->token['type'] === T::T_EOF) {
return $result;
}
throw $this->syntax('Did not reach the end of the token stream');
}
/**
* Parses an expression while rbp < lbp.
*
* @param int $rbp Right bound precedence
*
* @return array
*/
private function expr($rbp = 0)
{
$left = $this->{"nud_{$this->token['type']}"}();
while ($rbp < self::$bp[$this->token['type']]) {
$left = $this->{"led_{$this->token['type']}"}($left);
}
return $left;
}
private function nud_identifier()
{
$token = $this->token;
$this->next();
return ['type' => 'field', 'value' => $token['value']];
}
private function nud_quoted_identifier()
{
$token = $this->token;
$this->next();
$this->assertNotToken(T::T_LPAREN);
return ['type' => 'field', 'value' => $token['value']];
}
private function nud_current()
{
$this->next();
return self::$currentNode;
}
private function nud_literal()
{
$token = $this->token;
$this->next();
return ['type' => 'literal', 'value' => $token['value']];
}
private function nud_expref()
{
$this->next();
return ['type' => T::T_EXPREF, 'children' => [$this->expr(self::$bp[T::T_EXPREF])]];
}
private function nud_not()
{
$this->next();
return ['type' => T::T_NOT, 'children' => [$this->expr(self::$bp[T::T_NOT])]];
}
private function nud_lparen()
{
$this->next();
$result = $this->expr(0);
if ($this->token['type'] !== T::T_RPAREN) {
throw $this->syntax('Unclosed `(`');
}
$this->next();
return $result;
}
private function nud_lbrace()
{
static $validKeys = [T::T_QUOTED_IDENTIFIER => true, T::T_IDENTIFIER => true];
$this->next($validKeys);
$pairs = [];
do {
$pairs[] = $this->parseKeyValuePair();
if ($this->token['type'] == T::T_COMMA) {
$this->next($validKeys);
}
} while ($this->token['type'] !== T::T_RBRACE);
$this->next();
return['type' => 'multi_select_hash', 'children' => $pairs];
}
private function nud_flatten()
{
return $this->led_flatten(self::$currentNode);
}
private function nud_filter()
{
return $this->led_filter(self::$currentNode);
}
private function nud_star()
{
return $this->parseWildcardObject(self::$currentNode);
}
private function nud_lbracket()
{
$this->next();
$type = $this->token['type'];
if ($type == T::T_NUMBER || $type == T::T_COLON) {
return $this->parseArrayIndexExpression();
} elseif ($type == T::T_STAR && $this->lookahead() == T::T_RBRACKET) {
return $this->parseWildcardArray();
} else {
return $this->parseMultiSelectList();
}
}
private function led_lbracket(array $left)
{
static $nextTypes = [T::T_NUMBER => true, T::T_COLON => true, T::T_STAR => true];
$this->next($nextTypes);
switch ($this->token['type']) {
case T::T_NUMBER:
case T::T_COLON:
return [
'type' => 'subexpression',
'children' => [$left, $this->parseArrayIndexExpression()]
];
default:
return $this->parseWildcardArray($left);
}
}
private function led_flatten(array $left)
{
$this->next();
return [
'type' => 'projection',
'from' => 'array',
'children' => [
['type' => T::T_FLATTEN, 'children' => [$left]],
$this->parseProjection(self::$bp[T::T_FLATTEN])
]
];
}
private function led_dot(array $left)
{
$this->next(self::$afterDot);
if ($this->token['type'] == T::T_STAR) {
return $this->parseWildcardObject($left);
}
return [
'type' => 'subexpression',
'children' => [$left, $this->parseDot(self::$bp[T::T_DOT])]
];
}
private function led_or(array $left)
{
$this->next();
return [
'type' => T::T_OR,
'children' => [$left, $this->expr(self::$bp[T::T_OR])]
];
}
private function led_and(array $left)
{
$this->next();
return [
'type' => T::T_AND,
'children' => [$left, $this->expr(self::$bp[T::T_AND])]
];
}
private function led_pipe(array $left)
{
$this->next();
return [
'type' => T::T_PIPE,
'children' => [$left, $this->expr(self::$bp[T::T_PIPE])]
];
}
private function led_lparen(array $left)
{
$args = [];
$this->next();
while ($this->token['type'] != T::T_RPAREN) {
$args[] = $this->expr(0);
if ($this->token['type'] == T::T_COMMA) {
$this->next();
}
}
$this->next();
return [
'type' => 'function',
'value' => $left['value'],
'children' => $args
];
}
private function led_filter(array $left)
{
$this->next();
$expression = $this->expr();
if ($this->token['type'] != T::T_RBRACKET) {
throw $this->syntax('Expected a closing rbracket for the filter');
}
$this->next();
$rhs = $this->parseProjection(self::$bp[T::T_FILTER]);
return [
'type' => 'projection',
'from' => 'array',
'children' => [
$left ?: self::$currentNode,
[
'type' => 'condition',
'children' => [$expression, $rhs]
]
]
];
}
private function led_comparator(array $left)
{
$token = $this->token;
$this->next();
return [
'type' => T::T_COMPARATOR,
'value' => $token['value'],
'children' => [$left, $this->expr(self::$bp[T::T_COMPARATOR])]
];
}
private function parseProjection($bp)
{
$type = $this->token['type'];
if (self::$bp[$type] < 10) {
return self::$currentNode;
} elseif ($type == T::T_DOT) {
$this->next(self::$afterDot);
return $this->parseDot($bp);
} elseif ($type == T::T_LBRACKET || $type == T::T_FILTER) {
return $this->expr($bp);
}
throw $this->syntax('Syntax error after projection');
}
private function parseDot($bp)
{
if ($this->token['type'] == T::T_LBRACKET) {
$this->next();
return $this->parseMultiSelectList();
}
return $this->expr($bp);
}
private function parseKeyValuePair()
{
static $validColon = [T::T_COLON => true];
$key = $this->token['value'];
$this->next($validColon);
$this->next();
return [
'type' => 'key_val_pair',
'value' => $key,
'children' => [$this->expr()]
];
}
private function parseWildcardObject(array $left = null)
{
$this->next();
return [
'type' => 'projection',
'from' => 'object',
'children' => [
$left ?: self::$currentNode,
$this->parseProjection(self::$bp[T::T_STAR])
]
];
}
private function parseWildcardArray(array $left = null)
{
static $getRbracket = [T::T_RBRACKET => true];
$this->next($getRbracket);
$this->next();
return [
'type' => 'projection',
'from' => 'array',
'children' => [
$left ?: self::$currentNode,
$this->parseProjection(self::$bp[T::T_STAR])
]
];
}
/**
* Parses an array index expression (e.g., [0], [1:2:3]
*/
private function parseArrayIndexExpression()
{
static $matchNext = [
T::T_NUMBER => true,
T::T_COLON => true,
T::T_RBRACKET => true
];
$pos = 0;
$parts = [null, null, null];
$expected = $matchNext;
do {
if ($this->token['type'] == T::T_COLON) {
$pos++;
$expected = $matchNext;
} elseif ($this->token['type'] == T::T_NUMBER) {
$parts[$pos] = $this->token['value'];
$expected = [T::T_COLON => true, T::T_RBRACKET => true];
}
$this->next($expected);
} while ($this->token['type'] != T::T_RBRACKET);
// Consume the closing bracket
$this->next();
if ($pos === 0) {
// No colons were found so this is a simple index extraction
return ['type' => 'index', 'value' => $parts[0]];
}
if ($pos > 2) {
throw $this->syntax('Invalid array slice syntax: too many colons');
}
// Sliced array from start (e.g., [2:])
return [
'type' => 'projection',
'from' => 'array',
'children' => [
['type' => 'slice', 'value' => $parts],
$this->parseProjection(self::$bp[T::T_STAR])
]
];
}
private function parseMultiSelectList()
{
$nodes = [];
do {
$nodes[] = $this->expr();
if ($this->token['type'] == T::T_COMMA) {
$this->next();
$this->assertNotToken(T::T_RBRACKET);
}
} while ($this->token['type'] !== T::T_RBRACKET);
$this->next();
return ['type' => 'multi_select_list', 'children' => $nodes];
}
private function syntax($msg)
{
return new SyntaxErrorException($msg, $this->token, $this->expression);
}
private function lookahead()
{
return (!isset($this->tokens[$this->tpos + 1]))
? T::T_EOF
: $this->tokens[$this->tpos + 1]['type'];
}
private function next(array $match = null)
{
if (!isset($this->tokens[$this->tpos + 1])) {
$this->token = self::$nullToken;
} else {
$this->token = $this->tokens[++$this->tpos];
}
if ($match && !isset($match[$this->token['type']])) {
throw $this->syntax($match);
}
}
private function assertNotToken($type)
{
if ($this->token['type'] == $type) {
throw $this->syntax("Token {$this->tpos} not allowed to be $type");
}
}
/**
* @internal Handles undefined tokens without paying the cost of validation
*/
public function __call($method, $args)
{
$prefix = substr($method, 0, 4);
if ($prefix == 'nud_' || $prefix == 'led_') {
$token = substr($method, 4);
$message = "Unexpected \"$token\" token ($method). Expected one of"
. " the following tokens: "
. implode(', ', array_map(function ($i) {
return '"' . substr($i, 4) . '"';
}, array_filter(
get_class_methods($this),
function ($i) use ($prefix) {
return strpos($i, $prefix) === 0;
}
)));
throw $this->syntax($message);
}
throw new \BadMethodCallException("Call to undefined method $method");
}
}

View File

@@ -0,0 +1,36 @@
<?php
namespace JmesPath;
/**
* Syntax errors raise this exception that gives context
*/
class SyntaxErrorException extends \InvalidArgumentException
{
/**
* @param string $expectedTypesOrMessage Expected array of tokens or message
* @param array $token Current token
* @param string $expression Expression input
*/
public function __construct(
$expectedTypesOrMessage,
array $token,
$expression
) {
$message = "Syntax error at character {$token['pos']}\n"
. $expression . "\n" . str_repeat(' ', $token['pos']) . "^\n";
$message .= !is_array($expectedTypesOrMessage)
? $expectedTypesOrMessage
: $this->createTokenMessage($token, $expectedTypesOrMessage);
parent::__construct($message);
}
private function createTokenMessage(array $token, array $valid)
{
return sprintf(
'Expected one of the following: %s; found %s "%s"',
implode(', ', array_keys($valid)),
$token['type'],
$token['value']
);
}
}

View File

@@ -0,0 +1,419 @@
<?php
namespace JmesPath;
/**
* Tree visitor used to compile JMESPath expressions into native PHP code.
*/
class TreeCompiler
{
private $indentation;
private $source;
private $vars;
/**
* @param array $ast AST to compile.
* @param string $fnName The name of the function to generate.
* @param string $expr Expression being compiled.
*
* @return string
*/
public function visit(array $ast, $fnName, $expr)
{
$this->vars = [];
$this->source = $this->indentation = '';
$this->write("<?php\n")
->write('use JmesPath\\TreeInterpreter as Ti;')
->write('use JmesPath\\FnDispatcher as Fd;')
->write('use JmesPath\\Utils;')
->write('')
->write('function %s(Ti $interpreter, $value) {', $fnName)
->indent()
->dispatch($ast)
->write('')
->write('return $value;')
->outdent()
->write('}');
return $this->source;
}
/**
* @param array $node
* @return mixed
*/
private function dispatch(array $node)
{
return $this->{"visit_{$node['type']}"}($node);
}
/**
* Creates a monotonically incrementing unique variable name by prefix.
*
* @param string $prefix Variable name prefix
*
* @return string
*/
private function makeVar($prefix)
{
if (!isset($this->vars[$prefix])) {
$this->vars[$prefix] = 0;
return '$' . $prefix;
}
return '$' . $prefix . ++$this->vars[$prefix];
}
/**
* Writes the given line of source code. Pass positional arguments to write
* that match the format of sprintf.
*
* @param string $str String to write
* @return $this
*/
private function write($str)
{
$this->source .= $this->indentation;
if (func_num_args() == 1) {
$this->source .= $str . "\n";
return $this;
}
$this->source .= vsprintf($str, array_slice(func_get_args(), 1)) . "\n";
return $this;
}
/**
* Decreases the indentation level of code being written
* @return $this
*/
private function outdent()
{
$this->indentation = substr($this->indentation, 0, -4);
return $this;
}
/**
* Increases the indentation level of code being written
* @return $this
*/
private function indent()
{
$this->indentation .= ' ';
return $this;
}
private function visit_or(array $node)
{
$a = $this->makeVar('beforeOr');
return $this
->write('%s = $value;', $a)
->dispatch($node['children'][0])
->write('if (!$value && $value !== "0" && $value !== 0) {')
->indent()
->write('$value = %s;', $a)
->dispatch($node['children'][1])
->outdent()
->write('}');
}
private function visit_and(array $node)
{
$a = $this->makeVar('beforeAnd');
return $this
->write('%s = $value;', $a)
->dispatch($node['children'][0])
->write('if ($value || $value === "0" || $value === 0) {')
->indent()
->write('$value = %s;', $a)
->dispatch($node['children'][1])
->outdent()
->write('}');
}
private function visit_not(array $node)
{
return $this
->write('// Visiting not node')
->dispatch($node['children'][0])
->write('// Applying boolean not to result of not node')
->write('$value = !Utils::isTruthy($value);');
}
private function visit_subexpression(array $node)
{
return $this
->dispatch($node['children'][0])
->write('if ($value !== null) {')
->indent()
->dispatch($node['children'][1])
->outdent()
->write('}');
}
private function visit_field(array $node)
{
$arr = '$value[' . var_export($node['value'], true) . ']';
$obj = '$value->{' . var_export($node['value'], true) . '}';
$this->write('if (is_array($value) || $value instanceof \\ArrayAccess) {')
->indent()
->write('$value = isset(%s) ? %s : null;', $arr, $arr)
->outdent()
->write('} elseif ($value instanceof \\stdClass) {')
->indent()
->write('$value = isset(%s) ? %s : null;', $obj, $obj)
->outdent()
->write("} else {")
->indent()
->write('$value = null;')
->outdent()
->write("}");
return $this;
}
private function visit_index(array $node)
{
if ($node['value'] >= 0) {
$check = '$value[' . $node['value'] . ']';
return $this->write(
'$value = (is_array($value) || $value instanceof \\ArrayAccess)'
. ' && isset(%s) ? %s : null;',
$check, $check
);
}
$a = $this->makeVar('count');
return $this
->write('if (is_array($value) || ($value instanceof \\ArrayAccess && $value instanceof \\Countable)) {')
->indent()
->write('%s = count($value) + %s;', $a, $node['value'])
->write('$value = isset($value[%s]) ? $value[%s] : null;', $a, $a)
->outdent()
->write('} else {')
->indent()
->write('$value = null;')
->outdent()
->write('}');
}
private function visit_literal(array $node)
{
return $this->write('$value = %s;', var_export($node['value'], true));
}
private function visit_pipe(array $node)
{
return $this
->dispatch($node['children'][0])
->dispatch($node['children'][1]);
}
private function visit_multi_select_list(array $node)
{
return $this->visit_multi_select_hash($node);
}
private function visit_multi_select_hash(array $node)
{
$listVal = $this->makeVar('list');
$value = $this->makeVar('prev');
$this->write('if ($value !== null) {')
->indent()
->write('%s = [];', $listVal)
->write('%s = $value;', $value);
$first = true;
foreach ($node['children'] as $child) {
if (!$first) {
$this->write('$value = %s;', $value);
}
$first = false;
if ($node['type'] == 'multi_select_hash') {
$this->dispatch($child['children'][0]);
$key = var_export($child['value'], true);
$this->write('%s[%s] = $value;', $listVal, $key);
} else {
$this->dispatch($child);
$this->write('%s[] = $value;', $listVal);
}
}
return $this
->write('$value = %s;', $listVal)
->outdent()
->write('}');
}
private function visit_function(array $node)
{
$value = $this->makeVar('val');
$args = $this->makeVar('args');
$this->write('%s = $value;', $value)
->write('%s = [];', $args);
foreach ($node['children'] as $arg) {
$this->dispatch($arg);
$this->write('%s[] = $value;', $args)
->write('$value = %s;', $value);
}
return $this->write(
'$value = Fd::getInstance()->__invoke("%s", %s);',
$node['value'], $args
);
}
private function visit_slice(array $node)
{
return $this
->write('$value = !is_string($value) && !Utils::isArray($value)')
->write(' ? null : Utils::slice($value, %s, %s, %s);',
var_export($node['value'][0], true),
var_export($node['value'][1], true),
var_export($node['value'][2], true)
);
}
private function visit_current(array $node)
{
return $this->write('// Visiting current node (no-op)');
}
private function visit_expref(array $node)
{
$child = var_export($node['children'][0], true);
return $this->write('$value = function ($value) use ($interpreter) {')
->indent()
->write('return $interpreter->visit(%s, $value);', $child)
->outdent()
->write('};');
}
private function visit_flatten(array $node)
{
$this->dispatch($node['children'][0]);
$merged = $this->makeVar('merged');
$val = $this->makeVar('val');
$this
->write('// Visiting merge node')
->write('if (!Utils::isArray($value)) {')
->indent()
->write('$value = null;')
->outdent()
->write('} else {')
->indent()
->write('%s = [];', $merged)
->write('foreach ($value as %s) {', $val)
->indent()
->write('if (is_array(%s) && isset(%s[0])) {', $val, $val)
->indent()
->write('%s = array_merge(%s, %s);', $merged, $merged, $val)
->outdent()
->write('} elseif (%s !== []) {', $val)
->indent()
->write('%s[] = %s;', $merged, $val)
->outdent()
->write('}')
->outdent()
->write('}')
->write('$value = %s;', $merged)
->outdent()
->write('}');
return $this;
}
private function visit_projection(array $node)
{
$val = $this->makeVar('val');
$collected = $this->makeVar('collected');
$this->write('// Visiting projection node')
->dispatch($node['children'][0])
->write('');
if (!isset($node['from'])) {
$this->write('if (!is_array($value) || !($value instanceof \stdClass)) { $value = null; }');
} elseif ($node['from'] == 'object') {
$this->write('if (!Utils::isObject($value)) { $value = null; }');
} elseif ($node['from'] == 'array') {
$this->write('if (!Utils::isArray($value)) { $value = null; }');
}
$this->write('if ($value !== null) {')
->indent()
->write('%s = [];', $collected)
->write('foreach ((array) $value as %s) {', $val)
->indent()
->write('$value = %s;', $val)
->dispatch($node['children'][1])
->write('if ($value !== null) {')
->indent()
->write('%s[] = $value;', $collected)
->outdent()
->write('}')
->outdent()
->write('}')
->write('$value = %s;', $collected)
->outdent()
->write('}');
return $this;
}
private function visit_condition(array $node)
{
$value = $this->makeVar('beforeCondition');
return $this
->write('%s = $value;', $value)
->write('// Visiting condition node')
->dispatch($node['children'][0])
->write('// Checking result of condition node')
->write('if (Utils::isTruthy($value)) {')
->indent()
->write('$value = %s;', $value)
->dispatch($node['children'][1])
->outdent()
->write('} else {')
->indent()
->write('$value = null;')
->outdent()
->write('}');
}
private function visit_comparator(array $node)
{
$value = $this->makeVar('val');
$a = $this->makeVar('left');
$b = $this->makeVar('right');
$this
->write('// Visiting comparator node')
->write('%s = $value;', $value)
->dispatch($node['children'][0])
->write('%s = $value;', $a)
->write('$value = %s;', $value)
->dispatch($node['children'][1])
->write('%s = $value;', $b);
if ($node['value'] == '==') {
$this->write('$value = Utils::isEqual(%s, %s);', $a, $b);
} elseif ($node['value'] == '!=') {
$this->write('$value = !Utils::isEqual(%s, %s);', $a, $b);
} else {
$this->write(
'$value = (is_int(%s) || is_float(%s)) && (is_int(%s) || is_float(%s)) && %s %s %s;',
$a, $a, $b, $b, $a, $node['value'], $b
);
}
return $this;
}
/** @internal */
public function __call($method, $args)
{
throw new \RuntimeException(
sprintf('Invalid node encountered: %s', json_encode($args[0]))
);
}
}

View File

@@ -0,0 +1,235 @@
<?php
namespace JmesPath;
/**
* Tree visitor used to evaluates JMESPath AST expressions.
*/
class TreeInterpreter
{
/** @var callable */
private $fnDispatcher;
/**
* @param callable|null $fnDispatcher Function dispatching function that accepts
* a function name argument and an array of
* function arguments and returns the result.
*/
public function __construct(callable $fnDispatcher = null)
{
$this->fnDispatcher = $fnDispatcher ?: FnDispatcher::getInstance();
}
/**
* Visits each node in a JMESPath AST and returns the evaluated result.
*
* @param array $node JMESPath AST node
* @param mixed $data Data to evaluate
*
* @return mixed
*/
public function visit(array $node, $data)
{
return $this->dispatch($node, $data);
}
/**
* Recursively traverses an AST using depth-first, pre-order traversal.
* The evaluation logic for each node type is embedded into a large switch
* statement to avoid the cost of "double dispatch".
* @return mixed
*/
private function dispatch(array $node, $value)
{
$dispatcher = $this->fnDispatcher;
switch ($node['type']) {
case 'field':
if (is_array($value) || $value instanceof \ArrayAccess) {
return isset($value[$node['value']]) ? $value[$node['value']] : null;
} elseif ($value instanceof \stdClass) {
return isset($value->{$node['value']}) ? $value->{$node['value']} : null;
}
return null;
case 'subexpression':
return $this->dispatch(
$node['children'][1],
$this->dispatch($node['children'][0], $value)
);
case 'index':
if (!Utils::isArray($value)) {
return null;
}
$idx = $node['value'] >= 0
? $node['value']
: $node['value'] + count($value);
return isset($value[$idx]) ? $value[$idx] : null;
case 'projection':
$left = $this->dispatch($node['children'][0], $value);
switch ($node['from']) {
case 'object':
if (!Utils::isObject($left)) {
return null;
}
break;
case 'array':
if (!Utils::isArray($left)) {
return null;
}
break;
default:
if (!is_array($left) || !($left instanceof \stdClass)) {
return null;
}
}
$collected = [];
foreach ((array) $left as $val) {
$result = $this->dispatch($node['children'][1], $val);
if ($result !== null) {
$collected[] = $result;
}
}
return $collected;
case 'flatten':
static $skipElement = [];
$value = $this->dispatch($node['children'][0], $value);
if (!Utils::isArray($value)) {
return null;
}
$merged = [];
foreach ($value as $values) {
// Only merge up arrays lists and not hashes
if (is_array($values) && isset($values[0])) {
$merged = array_merge($merged, $values);
} elseif ($values !== $skipElement) {
$merged[] = $values;
}
}
return $merged;
case 'literal':
return $node['value'];
case 'current':
return $value;
case 'or':
$result = $this->dispatch($node['children'][0], $value);
return Utils::isTruthy($result)
? $result
: $this->dispatch($node['children'][1], $value);
case 'and':
$result = $this->dispatch($node['children'][0], $value);
return Utils::isTruthy($result)
? $this->dispatch($node['children'][1], $value)
: $result;
case 'not':
return !Utils::isTruthy(
$this->dispatch($node['children'][0], $value)
);
case 'pipe':
return $this->dispatch(
$node['children'][1],
$this->dispatch($node['children'][0], $value)
);
case 'multi_select_list':
if ($value === null) {
return null;
}
$collected = [];
foreach ($node['children'] as $node) {
$collected[] = $this->dispatch($node, $value);
}
return $collected;
case 'multi_select_hash':
if ($value === null) {
return null;
}
$collected = [];
foreach ($node['children'] as $node) {
$collected[$node['value']] = $this->dispatch(
$node['children'][0],
$value
);
}
return $collected;
case 'comparator':
$left = $this->dispatch($node['children'][0], $value);
$right = $this->dispatch($node['children'][1], $value);
if ($node['value'] == '==') {
return Utils::isEqual($left, $right);
} elseif ($node['value'] == '!=') {
return !Utils::isEqual($left, $right);
} else {
return self::relativeCmp($left, $right, $node['value']);
}
case 'condition':
return Utils::isTruthy($this->dispatch($node['children'][0], $value))
? $this->dispatch($node['children'][1], $value)
: null;
case 'function':
$args = [];
foreach ($node['children'] as $arg) {
$args[] = $this->dispatch($arg, $value);
}
return $dispatcher($node['value'], $args);
case 'slice':
return is_string($value) || Utils::isArray($value)
? Utils::slice(
$value,
$node['value'][0],
$node['value'][1],
$node['value'][2]
) : null;
case 'expref':
$apply = $node['children'][0];
return function ($value) use ($apply) {
return $this->visit($apply, $value);
};
default:
throw new \RuntimeException("Unknown node type: {$node['type']}");
}
}
/**
* @return bool
*/
private static function relativeCmp($left, $right, $cmp)
{
if (!(is_int($left) || is_float($left)) || !(is_int($right) || is_float($right))) {
return false;
}
switch ($cmp) {
case '>': return $left > $right;
case '>=': return $left >= $right;
case '<': return $left < $right;
case '<=': return $left <= $right;
default: throw new \RuntimeException("Invalid comparison: $cmp");
}
}
}

View File

@@ -0,0 +1,258 @@
<?php
namespace JmesPath;
class Utils
{
public static $typeMap = [
'boolean' => 'boolean',
'string' => 'string',
'NULL' => 'null',
'double' => 'number',
'float' => 'number',
'integer' => 'number'
];
/**
* Returns true if the value is truthy
*
* @param mixed $value Value to check
*
* @return bool
*/
public static function isTruthy($value)
{
if (!$value) {
return $value === 0 || $value === '0';
} elseif ($value instanceof \stdClass) {
return (bool) get_object_vars($value);
} else {
return true;
}
}
/**
* Gets the JMESPath type equivalent of a PHP variable.
*
* @param mixed $arg PHP variable
* @return string Returns the JSON data type
* @throws \InvalidArgumentException when an unknown type is given.
*/
public static function type($arg)
{
$type = gettype($arg);
if (isset(self::$typeMap[$type])) {
return self::$typeMap[$type];
} elseif ($type === 'array') {
if (empty($arg)) {
return 'array';
}
reset($arg);
return key($arg) === 0 ? 'array' : 'object';
} elseif ($arg instanceof \stdClass) {
return 'object';
} elseif ($arg instanceof \Closure) {
return 'expression';
} elseif ($arg instanceof \ArrayAccess
&& $arg instanceof \Countable
) {
return count($arg) == 0 || $arg->offsetExists(0)
? 'array'
: 'object';
} elseif (method_exists($arg, '__toString')) {
return 'string';
}
throw new \InvalidArgumentException(
'Unable to determine JMESPath type from ' . get_class($arg)
);
}
/**
* Determine if the provided value is a JMESPath compatible object.
*
* @param mixed $value
*
* @return bool
*/
public static function isObject($value)
{
if (is_array($value)) {
return !$value || array_keys($value)[0] !== 0;
}
// Handle array-like values. Must be empty or offset 0 does not exist
return $value instanceof \Countable && $value instanceof \ArrayAccess
? count($value) == 0 || !$value->offsetExists(0)
: $value instanceof \stdClass;
}
/**
* Determine if the provided value is a JMESPath compatible array.
*
* @param mixed $value
*
* @return bool
*/
public static function isArray($value)
{
if (is_array($value)) {
return !$value || array_keys($value)[0] === 0;
}
// Handle array-like values. Must be empty or offset 0 exists.
return $value instanceof \Countable && $value instanceof \ArrayAccess
? count($value) == 0 || $value->offsetExists(0)
: false;
}
/**
* JSON aware value comparison function.
*
* @param mixed $a First value to compare
* @param mixed $b Second value to compare
*
* @return bool
*/
public static function isEqual($a, $b)
{
if ($a === $b) {
return true;
} elseif ($a instanceof \stdClass) {
return self::isEqual((array) $a, $b);
} elseif ($b instanceof \stdClass) {
return self::isEqual($a, (array) $b);
} else {
return false;
}
}
/**
* Safely add together two values.
*
* @param mixed $a First value to add
* @param mixed $b Second value to add
*
* @return int|float
*/
public static function add($a, $b)
{
if (is_numeric($a)) {
if (is_numeric($b)) {
return $a + $b;
} else {
return $a;
}
} else {
if (is_numeric($b)) {
return $b;
} else {
return 0;
}
}
}
/**
* JMESPath requires a stable sorting algorithm, so here we'll implement
* a simple Schwartzian transform that uses array index positions as tie
* breakers.
*
* @param array $data List or map of data to sort
* @param callable $sortFn Callable used to sort values
*
* @return array Returns the sorted array
* @link http://en.wikipedia.org/wiki/Schwartzian_transform
*/
public static function stableSort(array $data, callable $sortFn)
{
// Decorate each item by creating an array of [value, index]
array_walk($data, function (&$v, $k) {
$v = [$v, $k];
});
// Sort by the sort function and use the index as a tie-breaker
uasort($data, function ($a, $b) use ($sortFn) {
return $sortFn($a[0], $b[0]) ?: ($a[1] < $b[1] ? -1 : 1);
});
// Undecorate each item and return the resulting sorted array
return array_map(function ($v) {
return $v[0];
}, array_values($data));
}
/**
* Creates a Python-style slice of a string or array.
*
* @param array|string $value Value to slice
* @param int|null $start Starting position
* @param int|null $stop Stop position
* @param int $step Step (1, 2, -1, -2, etc.)
*
* @return array|string
* @throws \InvalidArgumentException
*/
public static function slice($value, $start = null, $stop = null, $step = 1)
{
if (!is_array($value) && !is_string($value)) {
throw new \InvalidArgumentException('Expects string or array');
}
return self::sliceIndices($value, $start, $stop, $step);
}
private static function adjustEndpoint($length, $endpoint, $step)
{
if ($endpoint < 0) {
$endpoint += $length;
if ($endpoint < 0) {
$endpoint = $step < 0 ? -1 : 0;
}
} elseif ($endpoint >= $length) {
$endpoint = $step < 0 ? $length - 1 : $length;
}
return $endpoint;
}
private static function adjustSlice($length, $start, $stop, $step)
{
if ($step === null) {
$step = 1;
} elseif ($step === 0) {
throw new \RuntimeException('step cannot be 0');
}
if ($start === null) {
$start = $step < 0 ? $length - 1 : 0;
} else {
$start = self::adjustEndpoint($length, $start, $step);
}
if ($stop === null) {
$stop = $step < 0 ? -1 : $length;
} else {
$stop = self::adjustEndpoint($length, $stop, $step);
}
return [$start, $stop, $step];
}
private static function sliceIndices($subject, $start, $stop, $step)
{
$type = gettype($subject);
$len = $type == 'string' ? mb_strlen($subject, 'UTF-8') : count($subject);
list($start, $stop, $step) = self::adjustSlice($len, $start, $stop, $step);
$result = [];
if ($step > 0) {
for ($i = $start; $i < $stop; $i += $step) {
$result[] = $subject[$i];
}
} else {
for ($i = $start; $i > $stop; $i += $step) {
$result[] = $subject[$i];
}
}
return $type == 'string' ? implode('', $result) : $result;
}
}