Files

771 lines
22 KiB
PHP

<?php
/**
* Extension: Base fields.
*
* @since 5.1.0
*
* @package wsal
* @subpackage entities
*/
declare(strict_types=1);
namespace WSAL\Entities;
// Exit if accessed directly.
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
if ( ! class_exists( '\WSAL\Entities\Query_Builder_Parser' ) ) {
/**
* Takes QueryBuilder array and converts it into PHP compatible query which can be used in simple IF statement.
*
* @since 5.2.1
*/
class Query_Builder_Parser {
/**
* Operators which required array values
*
* @var array
*
* @since 5.2.1
*/
protected static $needs_array = array(
'IN',
'NOT IN',
'BETWEEN',
'NOT BETWEEN',
);
/**
* SQL conditions to PHP conditions mapper
*
* @var array
*
* @since 5.2.1
*/
public static $conditions = array(
'and' => 'AND',
'or' => 'OR',
);
/**
* Prepared PHP query string
*
* @var string
*
* @since 5.2.1
*/
private static $query = '';
/**
* Operators and modifiers they support.
*
* @var array
*
* @since 5.2.1
*/
protected static $operators = array(
'equal' => array(
'accept_values' => true,
'apply_to' => array( 'string', 'number', 'datetime' ),
),
'not_equal' => array(
'accept_values' => true,
'apply_to' => array( 'string', 'number', 'datetime' ),
),
'in' => array(
'accept_values' => true,
'apply_to' => array( 'string', 'number', 'datetime' ),
),
'not_in' => array(
'accept_values' => true,
'apply_to' => array( 'string', 'number', 'datetime' ),
),
'less' => array(
'accept_values' => true,
'apply_to' => array( 'number', 'datetime' ),
),
'less_or_equal' => array(
'accept_values' => true,
'apply_to' => array( 'number', 'datetime' ),
),
'greater' => array(
'accept_values' => true,
'apply_to' => array( 'number', 'datetime' ),
),
'greater_or_equal' => array(
'accept_values' => true,
'apply_to' => array( 'number', 'datetime' ),
),
'between' => array(
'accept_values' => true,
'apply_to' => array( 'number', 'datetime' ),
),
'not_between' => array(
'accept_values' => true,
'apply_to' => array( 'number', 'datetime' ),
),
'begins_with' => array(
'accept_values' => true,
'apply_to' => array( 'string' ),
),
'not_begins_with' => array(
'accept_values' => true,
'apply_to' => array( 'string' ),
),
'contains' => array(
'accept_values' => true,
'apply_to' => array( 'string' ),
),
'not_contains' => array(
'accept_values' => true,
'apply_to' => array( 'string' ),
),
'ends_with' => array(
'accept_values' => true,
'apply_to' => array( 'string' ),
),
'not_ends_with' => array(
'accept_values' => true,
'apply_to' => array( 'string' ),
),
'is_empty' => array(
'accept_values' => false,
'apply_to' => array( 'string' ),
),
'is_not_empty' => array(
'accept_values' => false,
'apply_to' => array( 'string' ),
),
'is_null' => array(
'accept_values' => false,
'apply_to' => array( 'string', 'number', 'datetime' ),
),
'is_not_null' => array(
'accept_values' => false,
'apply_to' => array( 'string', 'number', 'datetime' ),
),
);
/**
* SQL operators mapper (equal is converted to PHP)
*
* @var array
*
* @since 5.2.1
*/
protected static $operator_sql = array(
'equal' => array( 'operator' => '=' ),
'not_equal' => array( 'operator' => '!=' ),
'in' => array( 'operator' => 'IN' ),
'not_in' => array( 'operator' => 'NOT IN' ),
'less' => array( 'operator' => '<' ),
'less_or_equal' => array( 'operator' => '<=' ),
'greater' => array( 'operator' => '>' ),
'greater_or_equal' => array( 'operator' => '>=' ),
'between' => array( 'operator' => 'BETWEEN' ),
'not_between' => array( 'operator' => 'NOT BETWEEN' ),
'begins_with' => array(
'operator' => 'LIKE',
'prepend' => '%',
),
'not_begins_with' => array(
'operator' => 'NOT LIKE',
'prepend' => '%',
),
'contains' => array(
'operator' => 'LIKE',
'append' => '%',
'prepend' => '%',
),
'not_contains' => array(
'operator' => 'NOT LIKE',
'append' => '%',
'prepend' => '%',
),
'ends_with' => array(
'operator' => 'LIKE',
'append' => '%',
),
'not_ends_with' => array(
'operator' => 'NOT LIKE',
'append' => '%',
),
'is_empty' => array( 'operator' => '=' ),
'is_not_empty' => array( 'operator' => '!=' ),
'is_null' => array( 'operator' => 'NULL' ),
'is_not_null' => array( 'operator' => 'NOT NULL' ),
);
/**
* Query_Builder_Parser's parse function!
*
* Build a query based on JSON that has been passed into the function, onto the builder passed into the function.
*
* @param string $json - The JSON string to be parsed.
*
* @throws \Exception - If something bad happens during parsing.
*
* @return string
*/
public static function parse( $json ) {
self::$query = '';
// do a JSON decode (throws exceptions if there is a JSON error...) .
$query = self::decode_json( $json );
// This can happen if the querybuilder had no rules...
if ( ! isset( $query['rules'] ) || ! is_array( $query['rules'] ) ) {
return '';
}
if ( empty( $query['rules'] ) ) {
return '';
}
// This shouldn't ever cause an issue, but may as well not go through the rules.
if ( count( $query['rules'] ) < 1 ) {
return '';
}
self::loop_through_rules( $query['rules'], $query['condition'] );
return self::strip_last_condition( self::$query, $query['condition'] );
}
/**
* Returns the sql operators array.
*
* @return array
*
* @since 5.3.0
*/
public static function get_operator_sql(): array {
return self::$operator_sql;
}
/**
* Called by parse, loops through all the rules to find out if nested or not.
*
* @param array $rules - Rules to be parsed.
* @param string $query_condition - The SQL condition to be applied.
*
* @throws \Exception - If parsing fails.
*/
protected static function loop_through_rules( array $rules, $query_condition = 'AND' ) {
foreach ( $rules as $rule ) {
/*
* If make_query does not see the correct fields, it will return the QueryBuilder without modifications
*/
self::$query .= self::make_query( $rule, $query_condition );
if ( self::is_nested( $rule ) ) {
self::$query .= '( ' . self::strip_last_condition( self::create_nested_query( $rule, $query_condition ), $query_condition ) . ' ) ' . $rule['condition'] . ' ';
}
}
if ( ! isset( $rule ) || ! isset( $rule['condition'] ) ) {
return;
}
self::$query = self::strip_last_condition( self::$query, $rule['condition'] );
}
/**
* If that is the final string - lets strip the last condition from it - it is not needed.
*
* @param string $query - The query string.
* @param string $query_condition - The condition string.
*
* @return string
*
* @since 5.2.1
*/
private static function strip_last_condition( string $query, string $query_condition ): string {
$condition = self::$conditions[ strtolower( $query_condition ) ];
return \rtrim( $query, $condition . ' ' );
}
/**
* Determine if a particular rule is actually a group of other rules.
*
* @param mixed $rule - Array of rules.
*
* @return bool
*/
protected static function is_nested( $rule ) {
if ( isset( $rule['rules'] ) && is_array( $rule['rules'] ) && count( $rule['rules'] ) > 0 ) {
return true;
}
return false;
}
/**
* Create nested queries
*
* When a rule is actually a group of rules, we want to build a nested query with the specified condition (AND/OR)
*
* @param array $rule - The rule to build query from.
* @param string|null $condition - The SQL condition to be applied to the query.
*
* @return string
*/
protected static function create_nested_query( array $rule, $condition = null ) {
if ( null === $condition ) {
$condition = $rule['condition'];
}
$query_builder = '';
$condition = self::validate_condition( $condition );
foreach ( $rule['rules'] as $loop_rule ) {
$function = 'make_query';
if ( self::is_nested( $loop_rule ) ) {
$function = 'create_nested_query';
}
if ( method_exists( __CLASS__, $function ) ) {
$query_builder .= call_user_func_array( array( __CLASS__, $function ), array( $loop_rule, $rule['condition'] ) );
}
}
return self::strip_last_condition( $query_builder, $rule['condition'] );
}
/**
* Check if a given rule is correct.
*
* Just before making a query for a rule, we want to make sure that the field, operator and value are set
*
* @param array $rule - The rule to check.
*
* @return bool true if values are correct.
*/
protected static function check_rule_correct( array $rule ) {
if ( ! isset( $rule['operator'], $rule['id'], $rule['field'], $rule['type'] ) ) {
return false;
}
if ( ! isset( self::$operators[ $rule['operator'] ] ) ) {
return false;
}
return true;
}
/**
* Give back the correct value when we don't accept one.
*
* @param array $rule - The rule to check against.
*
* @return null|string
*/
protected static function operator_value_when_not_accepting_one( array $rule ) {
if ( 'is_empty' == $rule['operator'] || 'is_not_empty' == $rule['operator'] ) {
return '';
}
return null;
}
/**
* Ensure that the value for a field is correct.
*
* Append/Prepend values for SQL statements, etc.
*
* @param string $operator - The operator to check.
* @param array $rule - The rule to check.
* @param mixed $value - The value to check.
*
* @throws \Exception - If something goes wrong.
*
* @return string
*/
public static function get_correct_value( string $operator, array $rule, $value ) {
$field = $rule['field'];
$sql_operator = self::$operator_sql[ $rule['operator'] ];
$require_array = self::operator_requires_array( $operator );
$value = self::enforce_array_or_string( $require_array, $value, $field );
$value = self::append_operator_if_required( $require_array, $value, $sql_operator );
if ( 'string' === $rule['type'] || 'date' === $rule['type'] ) {
$value = "'" . $value . "'";
}
/*
* Turn datetime into Carbon object so that it works with "between" operators etc.
*/
// if ( 'date' == $rule['type'] ) {
// $value = $this->convertDatetimeToCarbon( $value );
// }
return $value;
}
/**
* The query maker!
*
* Take a particular rule and make build something that the QueryBuilder would be proud of.
*
* Make sure that all the correct fields are in the rule object then add the expression to
* the query that was given by the user to the QueryBuilder.
*
* @param array $rule - The rule array to check.
* @param string $query_condition - String with the query condition and/or...
*
* @throws \Exception - If there is something wrong.
*
* @return string
*/
protected static function make_query( array $rule, $query_condition = 'AND' ) {
/*
* Ensure that the value is correct for the rule, return query on exception
*/
self::validate_condition( $query_condition );
try {
$value = self::get_value_for_query_from_rule( $rule );
} catch ( \Exception $e ) {
return '';
}
return self::convert_incoming_qbto_query( $rule, $value, $query_condition );
}
/**
* Convert an incoming rule from jQuery QueryBuilder to the Eloquent Querybuilder
*
* (This used to be part of make_query, where the name made sense, but I pulled it
* out to reduce some duplicated code inside JoinSupportingQueryBuilder)
*
* @param array $rule - Array with rules.
* @param mixed $value - The value that needs to be queried in the database.
* @param string $query_condition - Condition - and/or...
*
* @return string
*/
protected static function convert_incoming_qbto_query( array $rule, $value, $query_condition = 'AND' ) {
/*
* Convert the Operator (LIKE/NOT LIKE/GREATER THAN) given to us by QueryBuilder
* into on one that we can use inside the SQL query
*/
$sql_operator = self::$operator_sql[ $rule['operator'] ];
$operator = $sql_operator['operator'];
$condition = self::$conditions[ strtolower( $query_condition ) ];
if ( self::operator_requires_array( $operator ) ) {
return self::make_query_when_array( $rule, $sql_operator, $value, $condition );
} elseif ( self::operator_is_null( $operator ) ) {
return self::make_query_when_null( $rule, $sql_operator, $condition );
}
return $rule['field'] . ' ' . $sql_operator['operator'] . ' ' . $value . ' ' . $condition . ' ';
}
/**
* Ensure that the value is correct for the rule, try and set it if it's not.
*
* @param array $rule - The array with rules.
*
* @return mixed
*/
protected static function get_value_for_query_from_rule( array $rule ) {
$value = self::get_rule_value( $rule );
/*
* If the SQL Operator is set not to have a value, make sure that we set the value to null.
*/
if ( false === self::$operators[ $rule['operator'] ]['accept_values'] ) {
return self::operator_value_when_not_accepting_one( $rule );
}
/*
* Convert the Operator (LIKE/NOT LIKE/GREATER THAN) given to us by QueryBuilder
* into on one that we can use inside the SQL query
*/
$sql_operator = self::$operator_sql[ $rule['operator'] ];
$operator = $sql_operator['operator'];
/*
* \o/ Ensure that the value is an array only if it should be.
*/
$value = self::get_correct_value( $operator, $rule, $value );
return $value;
}
/**
* Checks if given operator requires array of values.
*
* @param string $operator - The SQL operator to be applied.
*
* @return bool
*
* @since 5.2.1
*/
protected static function operator_requires_array( string $operator ): bool {
return in_array( $operator, self::$needs_array );
}
/**
* Determine if an operator is NULL/NOT NULL
*
* @param string $operator - The operator to check for.
*
* @return bool
*/
protected static function operator_is_null( $operator ): bool {
return ( 'NULL' == $operator || 'NOT NULL' == $operator ) ? true : false;
}
/**
* Make sure that a condition is either 'or' or 'and'.
*
* @param string $condition - The condition to validate.
*
* @return string
*
* @throws \Exception - If provided is not the condition we expect.
*/
protected static function validate_condition( $condition ) {
if ( is_null( $condition ) ) {
return $condition;
}
$condition = trim( strtolower( $condition ) );
if ( 'and' !== $condition && 'or' !== $condition ) {
throw new \Exception( "Condition can only be one of: 'and', 'or'." );
}
return $condition;
}
/**
* Enforce whether the value for a given field is the correct type
*
* @param bool $require_array - Value must be an array.
* @param mixed $value - The value we are checking against.
* @param string $field - The field that we are enforcing.
*
* @return mixed value after enforcement
*
* @throws \Exception - Value is not a correct type.
*/
protected static function enforce_array_or_string( $require_array, $value, $field ) {
self::check_field_is_an_array( $require_array, $value, $field );
if ( ! $require_array && is_array( $value ) ) {
return self::convert_array_to_flat_value( $field, $value );
}
return $value;
}
/**
* Ensure that a given field is an array if required.
*
* @see enforce_array_or_string
*
* @param boolean $require_array - Is array type required.
* @param mixed $value - The value to be checked.
* @param string $field - The name of the field to check.
*
* @throws \Exception - If field is array - that is not correct here.
*/
protected static function check_field_is_an_array( $require_array, $value, $field ) {
if ( $require_array && ! is_array( $value ) ) {
throw new \Exception( "Field ($field) should be an array, but it isn't." );
}
}
/**
* Convert an array with just one item to a string.
*
* In some instances, and array may be given when we want a string.
*
* @see enforce_array_or_string
*
* @param string $field - The name of the field to show in the error.
* @param mixed $value - The value to check and return first one if it is array.
*
* @return mixed
*
* @throws \Exception - Throws exception if field is not an array.
*/
protected static function convert_array_to_flat_value( $field, $value ) {
if ( count( $value ) !== 1 ) {
throw new \Exception( "Field ($field) should not be an array, but it is." );
}
return $value[0];
}
/**
* Append or prepend a string to the query if required.
*
* @param bool $require_array value must be an array.
* @param mixed $value the value we are checking against.
* @param mixed $sql_operator - The SQL operator.
*
* @return mixed $value
*/
protected static function append_operator_if_required( $require_array, $value, $sql_operator ) {
if ( ! $require_array ) {
if ( isset( $sql_operator['append'] ) ) {
$value = $sql_operator['append'] . $value;
}
if ( isset( $sql_operator['prepend'] ) ) {
$value = $value . $sql_operator['prepend'];
}
}
return $value;
}
/**
* Decode the given JSON
*
* @param string $json - The JSON string to decode.
*
* @throws \Exception - If the JSON string is invalid.
*
* @return array
*/
private static function decode_json( $json ) {
if ( null == $json || 'null' == $json ) {
return array();
}
$query = json_decode( $json, true );
if ( json_last_error() ) {
throw new \Exception( 'JSON parsing threw an error: ' . json_last_error_msg() );
}
if ( ! \is_array( $query ) ) {
throw new \Exception( 'The query is not valid JSON' );
}
return $query;
}
/**
* Get a value for a given rule.
*
* @param array $rule - The array with rules.
*
* @throws \Exception - Throws an exception if the rule is not correct.
*/
private static function get_rule_value( array $rule ) {
if ( ! self::check_rule_correct( $rule ) ) {
throw new \Exception();
}
return $rule['value'];
}
/**
* That method takes care of the arrays.
*
* Some types of SQL Operators (ie, those that deal with lists/arrays) have specific requirements.
* This function enforces those requirements.
*
* @param array $rule - The array with the rules.
* @param array $sql_operator - The SQL Operator.
* @param array $value - The value to be checked.
* @param string $condition - The SQL condition to be applied.
*
* @return string
*
* @throws \Exception - If there is no value to be returned.
*/
protected static function make_query_when_array( array $rule, array $sql_operator, array $value, $condition ) {
if ( 'IN' == $sql_operator['operator'] || 'NOT IN' == $sql_operator['operator'] ) {
return self::make_array_query_in( $rule, $sql_operator['operator'], $value, $condition );
} elseif ( 'BETWEEN' == $sql_operator['operator'] || 'NOT BETWEEN' == $sql_operator['operator'] ) {
return self::make_array_query_between( $rule, $sql_operator['operator'], $value, $condition );
}
throw new \Exception( 'make_query_when_array could not return a value' );
}
/**
* Create a 'null' query when required.
*
* @param array $rule - Array of rules.
* @param array $sql_operator - The SQL operator.
* @param string $condition - The condition.
*
* @return String
*
* @throws \Exception - When SQL operator is !null.
*/
protected static function make_query_when_null( array $rule, array $sql_operator, $condition ) {
if ( 'NULL' == $sql_operator['operator'] ) {
return 'null == \$' . $rule['field'] . ' ' . $condition . ' ';
} elseif ( 'NOT NULL' == $sql_operator['operator'] ) {
return 'null != \$' . $rule['field'] . ' ' . $condition . ' ';
}
throw new \Exception( 'make_query_when_null was called on an SQL operator that is not null' );
}
/**
* Method to apply when the query is an IN or NOT IN...
*
* @see make_query_when_array
*
* @param array $rule - Array with rules.
* @param string $operator - The SQL operator to be used.
* @param array $value - The value to be checked.
* @param string $condition - The condition.
*
* @return string
*/
private static function make_array_query_in( array $rule, $operator, array $value, $condition ) {
if ( 'NOT IN' == $operator ) {
return '! in_array( \$' . $rule['field'] . ', array( ' . \implode( ', ', $value ) . ' ) ) ' . $condition . ' ';
}
return 'in_array( \$' . $rule['field'] . ', array( ' . \implode( ', ', $value ) . ' ) ) ' . $condition . ' ';
}
/**
* Method for cases, when the query is a BETWEEN or NOT BETWEEN...
*
* @see make_query_when_array
*
* @param array $rule - The array of rules.
* @param string $operator - The SQL operator used. [BETWEEN|NOT BETWEEN].
* @param array $value - Array of values to be matched.
* @param string $condition - The SQL condition.
*
* @throws \Exception When more then two items given for the between.
*
* @return string
*/
private static function make_array_query_between( array $rule, $operator, array $value, $condition ) {
if ( count( $value ) !== 2 ) {
throw new \Exception( "{$rule['field']} should be an array with only two items." );
}
if ( 'NOT BETWEEN' == $operator ) {
return '!( \$' . $rule['field'] . ' >= ' . $value[0] . ' && \$' . $rule['field'] . ' <= ' . $value[1] . ') ' . $condition . ' ';
}
return '( \$' . $rule['field'] . ' >= ' . $value[0] . ' && \$' . $rule['field'] . ' <= ' . $value[1] . ') ' . $condition . ' ';
}
}
}