Skip to content

Commit

Permalink
Cast datetimes and add 'not between' operator (#22) /ht @sgjackman & @…
Browse files Browse the repository at this point in the history
…wpanec-uno

* Added 'not_between' operator support to the join supporting parser
* Fixed datetimes so that they are cast correctly from Carbon instances.
* Ensure that all the code paths are tested
  • Loading branch information
timgws authored Nov 2, 2017
1 parent 2544861 commit 7c13842
Show file tree
Hide file tree
Showing 8 changed files with 195 additions and 22 deletions.
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
vendor/**
.idea/
composer.lock
13 changes: 10 additions & 3 deletions src/QueryBuilderParser/JoinSupportingQueryBuilderParser.php
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,13 @@ private function buildRequireArrayQuery($subclause, Builder $query)
}

$query->whereBetween($subclause['to_value_column'], $subclause['value']);
} elseif ($subclause['operator'] == 'NOT BETWEEN') {
if (count($subclause['value']) !== 2) {
throw new QBParseException($subclause['to_value_column'].
' should be an array with only two items.');
}

$query->whereNotBetween($subclause['to_value_column'], $subclause['value']);
}

return $query;
Expand Down Expand Up @@ -192,9 +199,9 @@ private function buildRequireNotArrayQuery($subclause, Builder $query)
*/
private function buildSubclauseWithNull($subclause, Builder $query, $isNotNull = false)
{
if ($isNotNull === true) {
return $query->whereNotNull($subclause['to_value_column']);
}
if ($isNotNull === true) {
return $query->whereNotNull($subclause['to_value_column']);
}

return $query->whereNull($subclause['to_value_column']);
}
Expand Down
38 changes: 32 additions & 6 deletions src/QueryBuilderParser/QBPFunctions.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

use \Illuminate\Database\Query\Builder;
use \stdClass;
use \Carbon\Carbon;

trait QBPFunctions
{
Expand All @@ -21,6 +22,7 @@ abstract protected function checkRuleCorrect(stdClass $rule);
'greater' => array ('accept_values' => true, 'apply_to' => ['number', 'datetime']),
'greater_or_equal' => array ('accept_values' => true, 'apply_to' => ['number', 'datetime']),
'between' => array ('accept_values' => true, 'apply_to' => ['number', 'datetime']),
'not_between' => array ('accept_values' => true, 'apply_to' => ['number', 'datetime']),
'begins_with' => array ('accept_values' => true, 'apply_to' => ['string']),
'not_begins_with' => array ('accept_values' => true, 'apply_to' => ['string']),
'contains' => array ('accept_values' => true, 'apply_to' => ['string']),
Expand All @@ -43,6 +45,7 @@ abstract protected function checkRuleCorrect(stdClass $rule);
'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' => '%'),
Expand All @@ -56,7 +59,7 @@ abstract protected function checkRuleCorrect(stdClass $rule);
);

protected $needs_array = array(
'IN', 'NOT IN', 'BETWEEN',
'IN', 'NOT IN', 'BETWEEN', 'NOT BETWEEN',
);

/**
Expand Down Expand Up @@ -157,6 +160,24 @@ protected function convertArrayToFlatValue($field, $value)
return $value[0];
}

/**
* Convert a Datetime field to Carbon items to be used for comparisons.
*
* @param $value
* @return \Carbon\Carbon
* @throws QBParseException
*/
protected function convertDatetimeToCarbon($value)
{
if (is_array($value)) {
return array_map(function ($v) {
return new Carbon($v);
}, $value);
}

return new Carbon($value);
}

/**
* Append or prepend a string to the query if required.
*
Expand Down Expand Up @@ -253,8 +274,8 @@ protected function makeQueryWhenArray(Builder $query, stdClass $rule, array $sql
{
if ($sqlOperator['operator'] == 'IN' || $sqlOperator['operator'] == 'NOT IN') {
return $this->makeArrayQueryIn($query, $rule, $sqlOperator['operator'], $value, $condition);
} elseif ($sqlOperator['operator'] == 'BETWEEN') {
return $this->makeArrayQueryBetween($query, $rule, $value, $condition);
} elseif ($sqlOperator['operator'] == 'BETWEEN' || $sqlOperator['operator'] == 'NOT BETWEEN') {
return $this->makeArrayQueryBetween($query, $rule, $sqlOperator['operator'], $value, $condition);
}

throw new QBParseException('makeQueryWhenArray could not return a value');
Expand All @@ -266,9 +287,9 @@ protected function makeQueryWhenArray(Builder $query, stdClass $rule, array $sql
* @param Builder $query
* @param stdClass $rule
* @param array $sqlOperator
* @param array $value
* @param string $condition
*
* @throws QBParseException when SQL operator is !null
* @return Builder
*/
protected function makeQueryWhenNull(Builder $query, stdClass $rule, array $sqlOperator, $condition)
Expand Down Expand Up @@ -304,22 +325,27 @@ private function makeArrayQueryIn(Builder $query, stdClass $rule, $operator, arr


/**
* makeArrayQueryBetween, when the query is an IN or NOT IN...
* makeArrayQueryBetween, when the query is a BETWEEN or NOT BETWEEN...
*
* @see makeQueryWhenArray
* @param Builder $query
* @param stdClass $rule
* @param string operator the SQL operator used. [BETWEEN|NOT BETWEEN]
* @param array $value
* @param string $condition
* @throws QBParseException when more then two items given for the between
* @return Builder
*/
private function makeArrayQueryBetween(Builder $query, stdClass $rule, array $value, $condition)
private function makeArrayQueryBetween(Builder $query, stdClass $rule, $operator, array $value, $condition)
{
if (count($value) !== 2) {
throw new QBParseException("{$rule->field} should be an array with only two items.");
}

if ( $operator == 'NOT BETWEEN' ) {
return $query->whereNotBetween( $rule->field, $value, $condition );
}

return $query->whereBetween($rule->field, $value, $condition);
}
}
26 changes: 17 additions & 9 deletions src/QueryBuilderParser/QueryBuilderParser.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

namespace timgws;

use \Carbon\Carbon;
use \stdClass;
use \Illuminate\Database\Query\Builder;
use \timgws\QBParseException;
Expand Down Expand Up @@ -53,9 +54,9 @@ public function parse($json, Builder $querybuilder)
/**
* Called by parse, loops through all the rules to find out if nested or not.
*
* @param array $rules
* @param array $rules
* @param Builder $querybuilder
* @param string $queryCondition
* @param string $queryCondition
*
* @throws QBParseException
*
Expand Down Expand Up @@ -111,7 +112,7 @@ protected function createNestedQuery(Builder $querybuilder, stdClass $rule, $con

$condition = $this->validateCondition($condition);

return $querybuilder->whereNested(function($query) use (&$rule, &$querybuilder, &$condition) {
return $querybuilder->whereNested(function ($query) use (&$rule, &$querybuilder, &$condition) {
foreach ($rule->rules as $loopRule) {
$function = 'makeQuery';

Expand Down Expand Up @@ -184,6 +185,13 @@ protected function getCorrectValue($operator, stdClass $rule, $value)

$value = $this->enforceArrayOrString($requireArray, $value, $field);

/*
* Turn datetime into Carbon object so that it works with "between" operators etc.
*/
if ($rule->type == 'date') {
$value = $this->convertDatetimeToCarbon($value);
}

return $this->appendOperatorIfRequired($requireArray, $value, $sqlOperator);
}

Expand All @@ -195,9 +203,9 @@ protected function getCorrectValue($operator, stdClass $rule, $value)
* 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 Builder $query
* @param Builder $query
* @param stdClass $rule
* @param string $queryCondition and/or...
* @param string $queryCondition and/or...
*
* @throws QBParseException
*
Expand All @@ -223,10 +231,10 @@ protected function makeQuery(Builder $query, stdClass $rule, $queryCondition = '
* (This used to be part of makeQuery, where the name made sense, but I pulled it
* out to reduce some duplicated code inside JoinSupportingQueryBuilder)
*
* @param Builder $query
* @param Builder $query
* @param stdClass $rule
* @param mixed $value the value that needs to be queried in the database.
* @param string $queryCondition and/or...
* @param mixed $value the value that needs to be queried in the database.
* @param string $queryCondition and/or...
* @return Builder
*/
protected function convertIncomingQBtoQuery(Builder $query, stdClass $rule, $value, $queryCondition = 'AND')
Expand All @@ -241,7 +249,7 @@ protected function convertIncomingQBtoQuery(Builder $query, stdClass $rule, $val

if ($this->operatorRequiresArray($operator)) {
return $this->makeQueryWhenArray($query, $rule, $sqlOperator, $value, $condition);
} elseif($this->operatorIsNull($operator)) {
} elseif ($this->operatorIsNull($operator)) {
return $this->makeQueryWhenNull($query, $rule, $sqlOperator, $condition);
}

Expand Down
3 changes: 3 additions & 0 deletions tests/CommonQueryBuilderTests.php
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,9 @@ protected function getParserUnderTest($fields = null)
return new QueryBuilderParser($fields);
}

/**
* @return Builder
*/
protected function createQueryBuilder()
{
$pdo = new \PDO('sqlite::memory:');
Expand Down
81 changes: 79 additions & 2 deletions tests/JoinSupportingQueryBuilderParserTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,19 @@ public function testJoinBetween()
$builder->toSql());
}

public function testJoinNotBetween()
{
$json = '{"condition":"AND","rules":[{"id":"join1","field":"join1","type":"text","input":"select","operator":"not_between","value":["a","b"]}]}';

$builder = $this->createQueryBuilder();

$parser = $this->getParserUnderTest();
$test = $parser->parse($json, $builder);

$this->assertEquals('select * where exists (select 1 from `subtable` where subtable.s_col = master.m_col and `s_value` not between ? and ?)',
$builder->toSql());
}

public function testJoinNotExistsBetween()
{
$json = '{"condition":"AND","rules":[{"id":"join2","field":"join2","type":"text","input":"select","operator":"between","value":["a","b"]}]}';
Expand Down Expand Up @@ -182,14 +195,36 @@ public function testJoinIsNotNull()
*/
public function testJoinNotExistsBetweenWithThreeItems()
{
$json = '{"condition":"AND","rules":[{"id":"join2","field":"join2","type":"text","input":"select","operator":"between","value":["a","b","c"]}]}';
$this->_testJoinNotExistsBetweenWithThreeItems(false);
}

/**
* @expectedException timgws\QBParseException
* @expectedExceptionMessage s2_value should be an array with only two items.
*
* @throws \timgws\QBParseException
*/
public function testJoinNotExistsNotBetweenWithThreeItems()
{
$this->_testJoinNotExistsBetweenWithThreeItems(true);
}

/**
* @see testJoinNotExistsBetweenWithThreeItems()
* @see testJoinNotExistsNotBetweenWithThreeItems()
*/
private function _testJoinNotExistsBetweenWithThreeItems($not_between = false)
{
$json_operator = ($not_between ? 'not_' : '') . 'between';
$sql_operator = ($not_between ? 'not ' : '') . 'between';
$json = '{"condition":"AND","rules":[{"id":"join2","field":"join2","type":"text","input":"select","operator":"'. $json_operator . '","value":["a","b","c"]}]}';

$builder = $this->createQueryBuilder();

$parser = $this->getParserUnderTest();
$parser->parse($json, $builder);

$this->assertEquals('select * where not exists (select 1 from `subtable2` where subtable2.s2_col = master2.m2_col and `s2_value` between ? and ?)',
$this->assertEquals('select * where not exists (select 1 from `subtable2` where subtable2.s2_col = master2.m2_col and `s2_value` ' . $sql_operator . ' ? and ?)',
$builder->toSql());
}

Expand Down Expand Up @@ -251,4 +286,46 @@ public function testCategoryIn()

$this->assertEquals('select * where `price` < ? and (`category` in (?, ?))', $builder->toSql());
}

/**
* Test for #21 (Cast datetimes and add 'not between' operator)
*/
public function testDateBetween()
{
$incoming = '{ "condition": "AND", "rules": [ { "id": "dollar_amount", "field": "dollar_amount", "type": "double", "input": "number", "operator": "less", "value": "546" }, { "id": "needed_by_date", "field": "needed_by_date", "type": "date", "input": "text", "operator": "between", "value": [ "10/22/2017", "10/28/2017" ] } ], "not": false, "valid": true }';
$builder = $this->createQueryBuilder();
$qb = $this->getParserUnderTest();

$qb->parse($incoming, $builder);

$this->assertEquals('select * where `dollar_amount` < ? and `needed_by_date` between ? and ?', $builder->toSql());

$bindings = $builder->getBindings();
$this->assertCount(3, $bindings);
$this->assertEquals('546', $bindings[0]);
$this->assertInstanceOf("Carbon\\Carbon", $bindings[1]);
$this->assertInstanceOf("Carbon\\Carbon", $bindings[2]);
}

/**
* Test for #21 (Cast datetimes and add 'not between' operator)
*/
public function testDateNotBetween()
{
$incoming = '{ "condition": "AND", "rules": [ { "id": "needed_by_date", "field": "needed_by_date", "type": "date", "input": "text", "operator": "not_between", "value": [ "10/22/2017", "10/28/2017" ] } ], "not": false, "valid": true }';
$builder = $this->createQueryBuilder();
$qb = $this->getParserUnderTest();

$qb->parse($incoming, $builder);

$this->assertEquals('select * where `needed_by_date` not between ? and ?', $builder->toSql());

$bindings = $builder->getBindings();
$this->assertCount(2, $bindings);
$this->assertInstanceOf("Carbon\\Carbon", $bindings[0]);
$this->assertInstanceOf("Carbon\\Carbon", $bindings[1]);
$this->assertEquals(2017, $bindings[0]->year);
$this->assertEquals(22, $bindings[0]->day);
$this->assertEquals(28, $bindings[1]->day);
}
}
28 changes: 28 additions & 0 deletions tests/QBPFunctionsTest.php
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<?php
namespace timgws\test;

use Carbon\Carbon;
use timgws\QBParseException;

/**
Expand Down Expand Up @@ -52,4 +53,31 @@ public function testOperatorNotValidForNull()
$builder, $rule->rules[1], array('operator' => 'CONTAINS'), array('AND'), 'AND'
]);
}

public function testDate()
{
$method = self::getMethod('convertDatetimeToCarbon');

$qb = $this->getParserUnderTest();

/** @var Carbon $carbonDate */
$carbonDate = $method->invokeArgs($qb, ['2010-12-11']);

$this->assertEquals('2010', $carbonDate->year);
$this->assertEquals('12', $carbonDate->month);
}

public function testDateArray()
{
$method = self::getMethod('convertDatetimeToCarbon');

$qb = $this->getParserUnderTest();

/** @var Carbon[] $carbonDate */
$carbonDates = $method->invokeArgs($qb, [['2010-12-11', '2001-01-02']]);

$this->assertCount(2, $carbonDates);
$this->assertEquals('2010', $carbonDates[0]->year);
$this->assertEquals('2001', $carbonDates[1]->year);
}
}
Loading

0 comments on commit 7c13842

Please sign in to comment.