mirror of
https://github.com/silverstripe/silverstripe-framework
synced 2024-10-22 14:05:37 +02:00
Merge pull request #4196 from tractorcow/pulls/4.0/null-filters
API Enable filters to perform 'IS NULL' or 'IS NOT NULL' checks
This commit is contained in:
commit
392536cb17
@ -355,6 +355,40 @@ You can use [SearchFilters](searchfilters) to add additional behavior to your `f
|
||||
'PlayerNumber:GreaterThan' => '10'
|
||||
));
|
||||
|
||||
### Filtering by null values
|
||||
|
||||
Since null values in SQL are special, they are non-comparable with other values, certain filters will add
|
||||
`IS NULL` or `IS NOT NULL` predicates automatically to your query. As per ANSI SQL-92, any comparison
|
||||
condition against a field will filter out nulls by default. Therefore, it's necessary to include certain null
|
||||
checks to ensure that exclusion filters behave predictably.
|
||||
|
||||
For instance, the below code will select only values that do not match the given value, including nulls.
|
||||
|
||||
|
||||
:::php
|
||||
$players = Player::get()->filter('FirstName:not', 'Sam');
|
||||
// ... WHERE "FirstName" != 'Sam' OR "FirstName" IS NULL
|
||||
// Returns rows with any value (even null) other than Sam
|
||||
|
||||
|
||||
If null values should be excluded, include the null in your check.
|
||||
|
||||
|
||||
:::php
|
||||
$players = Player::get()->filter('FirstName:not', array('Sam', null));
|
||||
// ... WHERE "FirstName" != 'Sam' AND "FirstName" IS NOT NULL
|
||||
// Only returns non-null values for "FirstName" that aren't Sam.
|
||||
// Strictly the IS NOT NULL isn't necessary, but is included for explicitness
|
||||
|
||||
|
||||
It is also often useful to filter by all rows with either empty or null for a given field.
|
||||
|
||||
|
||||
:::php
|
||||
$players = Player::get()->filter('FirstName', array(null, ''));
|
||||
// ... WHERE "FirstName" == '' OR "FirstName" IS NULL
|
||||
// Returns rows with FirstName which is either empty or null
|
||||
|
||||
|
||||
### filterByCallback
|
||||
|
||||
|
@ -5,6 +5,7 @@
|
||||
### Framework
|
||||
|
||||
* Deprecate `SQLQuery` in favour `SQLSelect`
|
||||
* `DataList::filter` by null now internally generates "IS NULL" or "IS NOT NULL" conditions appropriately on queries
|
||||
|
||||
## Upgrading
|
||||
|
||||
|
@ -352,6 +352,10 @@ class DataList extends ViewableData implements SS_List, SS_Filterable, SS_Sortab
|
||||
* @example $list = $list->filter(array('Name'=>array('aziz','bob'), 'Age'=>array(21, 43)));
|
||||
* // aziz with the age 21 or 43 and bob with the Age 21 or 43
|
||||
*
|
||||
* Note: When filtering on nullable columns, null checks will be automatically added.
|
||||
* E.g. ->filter('Field:not', 'value) will generate '... OR "Field" IS NULL', and
|
||||
* ->filter('Field:not', null) will generate '"Field" IS NOT NULL'
|
||||
*
|
||||
* @todo extract the sql from $customQuery into a SQLGenerator class
|
||||
*
|
||||
* @param string|array Escaped SQL statement. If passed as array, all keys and values will be escaped internally
|
||||
|
@ -267,6 +267,20 @@ abstract class SS_Database {
|
||||
$this->query("TRUNCATE \"$table\"");
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a WHERE clause for null comparison check
|
||||
*
|
||||
* @param string $field Quoted field name
|
||||
* @param bool $isNull Whether to check for NULL or NOT NULL
|
||||
* @return string Non-parameterised null comparison clause
|
||||
*/
|
||||
public function nullCheckClause($field, $isNull) {
|
||||
$clause = $isNull
|
||||
? "%s IS NULL"
|
||||
: "%s IS NOT NULL";
|
||||
return sprintf($clause, $field);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a WHERE clause for text matching.
|
||||
*
|
||||
|
@ -30,16 +30,51 @@ class ExactMatchFilter extends SearchFilter {
|
||||
* @return DataQuery
|
||||
*/
|
||||
protected function applyOne(DataQuery $query) {
|
||||
return $this->oneFilter($query, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Excludes an exact match (equals) on a field value.
|
||||
*
|
||||
* @return DataQuery
|
||||
*/
|
||||
protected function excludeOne(DataQuery $query) {
|
||||
return $this->oneFilter($query, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies a single match, either as inclusive or exclusive
|
||||
*
|
||||
* @param DataQuery $query
|
||||
* @param bool $inclusive True if this is inclusive, or false if exclusive
|
||||
* @return DataQuery
|
||||
*/
|
||||
protected function oneFilter(DataQuery $query, $inclusive) {
|
||||
$this->model = $query->applyRelation($this->relation);
|
||||
$field = $this->getDbName();
|
||||
$value = $this->getValue();
|
||||
|
||||
// Null comparison check
|
||||
if($value === null) {
|
||||
$where = DB::get_conn()->nullCheckClause($field, $inclusive);
|
||||
return $query->where($where);
|
||||
}
|
||||
|
||||
// Value comparison check
|
||||
$where = DB::get_conn()->comparisonClause(
|
||||
$this->getDbName(),
|
||||
$field,
|
||||
null,
|
||||
true, // exact?
|
||||
false, // negate?
|
||||
!$inclusive, // negate?
|
||||
$this->getCaseSensitive(),
|
||||
true
|
||||
);
|
||||
return $query->where(array($where => $this->getValue()));
|
||||
// for != clauses include IS NULL values, since they would otherwise be excluded
|
||||
if(!$inclusive) {
|
||||
$nullClause = DB::get_conn()->nullCheckClause($field, true);
|
||||
$where .= " OR {$nullClause}";
|
||||
}
|
||||
return $query->where(array($where => $value));
|
||||
}
|
||||
|
||||
/**
|
||||
@ -49,50 +84,7 @@ class ExactMatchFilter extends SearchFilter {
|
||||
* @return DataQuery
|
||||
*/
|
||||
protected function applyMany(DataQuery $query) {
|
||||
$this->model = $query->applyRelation($this->relation);
|
||||
$caseSensitive = $this->getCaseSensitive();
|
||||
$values = $this->getValue();
|
||||
if($caseSensitive === null) {
|
||||
// For queries using the default collation (no explicit case) we can use the WHERE .. IN .. syntax,
|
||||
// providing simpler SQL than many WHERE .. OR .. fragments.
|
||||
$column = $this->getDbName();
|
||||
$placeholders = DB::placeholders($values);
|
||||
return $query->where(array(
|
||||
"$column IN ($placeholders)" => $values
|
||||
));
|
||||
} else {
|
||||
$whereClause = array();
|
||||
$comparisonClause = DB::get_conn()->comparisonClause(
|
||||
$this->getDbName(),
|
||||
null,
|
||||
true, // exact?
|
||||
false, // negate?
|
||||
$caseSensitive,
|
||||
true
|
||||
);
|
||||
foreach($values as $value) {
|
||||
$whereClause[] = array($comparisonClause => $value);
|
||||
}
|
||||
return $query->whereAny($whereClause);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Excludes an exact match (equals) on a field value.
|
||||
*
|
||||
* @return DataQuery
|
||||
*/
|
||||
protected function excludeOne(DataQuery $query) {
|
||||
$this->model = $query->applyRelation($this->relation);
|
||||
$where = DB::get_conn()->comparisonClause(
|
||||
$this->getDbName(),
|
||||
null,
|
||||
true, // exact?
|
||||
true, // negate?
|
||||
$this->getCaseSensitive(),
|
||||
true
|
||||
);
|
||||
return $query->where(array($where => $this->getValue()));
|
||||
return $this->manyFilter($query, true);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -102,32 +94,93 @@ class ExactMatchFilter extends SearchFilter {
|
||||
* @return DataQuery
|
||||
*/
|
||||
protected function excludeMany(DataQuery $query) {
|
||||
return $this->manyFilter($query, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies matches for several values, either as inclusive or exclusive
|
||||
*
|
||||
* @param DataQuery $query
|
||||
* @param bool $inclusive True if this is inclusive, or false if exclusive
|
||||
* @return DataQuery
|
||||
*/
|
||||
protected function manyFilter(DataQuery $query, $inclusive) {
|
||||
$this->model = $query->applyRelation($this->relation);
|
||||
$caseSensitive = $this->getCaseSensitive();
|
||||
|
||||
// Check values for null
|
||||
$field = $this->getDbName();
|
||||
$values = $this->getValue();
|
||||
if($caseSensitive === null) {
|
||||
$hasNull = in_array(null, $values, true);
|
||||
if($hasNull) {
|
||||
$values = array_filter($values, function($value) {
|
||||
return $value !== null;
|
||||
});
|
||||
}
|
||||
|
||||
$connective = '';
|
||||
if(empty($values)) {
|
||||
$predicate = '';
|
||||
} elseif($caseSensitive === null) {
|
||||
// For queries using the default collation (no explicit case) we can use the WHERE .. NOT IN .. syntax,
|
||||
// providing simpler SQL than many WHERE .. AND .. fragments.
|
||||
$column = $this->getDbName();
|
||||
$placeholders = DB::placeholders($values);
|
||||
return $query->where(array(
|
||||
"$column NOT IN ($placeholders)" => $values
|
||||
));
|
||||
if($inclusive) {
|
||||
$predicate = "$column IN ($placeholders)";
|
||||
} else {
|
||||
$predicate = "$column NOT IN ($placeholders)";
|
||||
}
|
||||
} else {
|
||||
// Generate reusable comparison clause
|
||||
$comparisonClause = DB::get_conn()->comparisonClause(
|
||||
$this->getDbName(),
|
||||
null,
|
||||
true, // exact?
|
||||
true, // negate?
|
||||
!$inclusive, // negate?
|
||||
$this->getCaseSensitive(),
|
||||
true
|
||||
);
|
||||
// Since query connective is ambiguous, use AND explicitly here
|
||||
$count = count($values);
|
||||
$predicate = implode(' AND ', array_fill(0, $count, $comparisonClause));
|
||||
return $query->where(array($predicate => $values));
|
||||
if($count > 1) {
|
||||
$connective = $inclusive ? ' OR ' : ' AND ';
|
||||
$conditions = array_fill(0, $count, $comparisonClause);
|
||||
$predicate = implode($connective, $conditions);
|
||||
} else {
|
||||
$predicate = $comparisonClause;
|
||||
}
|
||||
}
|
||||
|
||||
// Always check for null when doing exclusive checks (either AND IS NOT NULL / OR IS NULL)
|
||||
// or when including the null value explicitly (OR IS NULL)
|
||||
if($hasNull || !$inclusive) {
|
||||
// If excluding values which don't include null, or including
|
||||
// values which include null, we should do an `OR IS NULL`.
|
||||
// Otherwise we are excluding values that do include null, so `AND IS NOT NULL`.
|
||||
// Simplified from (!$inclusive && !$hasNull) || ($inclusive && $hasNull);
|
||||
$isNull = !$hasNull || $inclusive;
|
||||
$nullCondition = DB::get_conn()->nullCheckClause($field, $isNull);
|
||||
|
||||
// Determine merge strategy
|
||||
if(empty($predicate)) {
|
||||
$predicate = $nullCondition;
|
||||
} else {
|
||||
// Merge null condition with predicate
|
||||
if($isNull) {
|
||||
$nullCondition = " OR {$nullCondition}";
|
||||
} else {
|
||||
$nullCondition = " AND {$nullCondition}";
|
||||
}
|
||||
// If current predicate connective doesn't match the same as the null connective
|
||||
// make sure to group the prior condition
|
||||
if($connective && (($connective === ' OR ') !== $isNull)) {
|
||||
$predicate = "({$predicate})";
|
||||
}
|
||||
$predicate .= $nullCondition;
|
||||
}
|
||||
}
|
||||
|
||||
return $query->where(array($predicate => $values));
|
||||
}
|
||||
|
||||
public function isEmpty() {
|
||||
|
@ -797,6 +797,180 @@ class DataListTest extends SapphireTest {
|
||||
$this->assertEquals(0, $list->exclude('ID', $obj->ID)->count());
|
||||
}
|
||||
|
||||
public function testFilterByNull() {
|
||||
$list = DataObjectTest_Fan::get();
|
||||
// Force DataObjectTest_Fan/fan5::Email to empty string
|
||||
$fan5id = $this->idFromFixture('DataObjectTest_Fan', 'fan5');
|
||||
DB::prepared_query("UPDATE \"DataObjectTest_Fan\" SET \"Email\" = '' WHERE \"ID\" = ?", array($fan5id));
|
||||
|
||||
// Filter by null email
|
||||
$nullEmails = $list->filter('Email', null);
|
||||
$this->assertDOSEquals(array(
|
||||
array(
|
||||
'Name' => 'Stephen',
|
||||
),
|
||||
array(
|
||||
'Name' => 'Mitch',
|
||||
)
|
||||
), $nullEmails);
|
||||
|
||||
// Filter by non-null
|
||||
$nonNullEmails = $list->filter('Email:not', null);
|
||||
$this->assertDOSEquals(array(
|
||||
array(
|
||||
'Name' => 'Damian',
|
||||
'Email' => 'damian@thefans.com',
|
||||
),
|
||||
array(
|
||||
'Name' => 'Richard',
|
||||
'Email' => 'richie@richers.com',
|
||||
),
|
||||
array(
|
||||
'Name' => 'Hamish',
|
||||
)
|
||||
), $nonNullEmails);
|
||||
|
||||
// Filter by empty only
|
||||
$emptyOnly = $list->filter('Email', '');
|
||||
$this->assertDOSEquals(array(
|
||||
array(
|
||||
'Name' => 'Hamish',
|
||||
)
|
||||
), $emptyOnly);
|
||||
|
||||
// Non-empty only. This should include null values, since ExactMatchFilter works around
|
||||
// the caveat that != '' also excludes null values in ANSI SQL-92 behaviour.
|
||||
$nonEmptyOnly = $list->filter('Email:not', '');
|
||||
$this->assertDOSEquals(array(
|
||||
array(
|
||||
'Name' => 'Damian',
|
||||
'Email' => 'damian@thefans.com',
|
||||
),
|
||||
array(
|
||||
'Name' => 'Richard',
|
||||
'Email' => 'richie@richers.com',
|
||||
),
|
||||
array(
|
||||
'Name' => 'Stephen',
|
||||
),
|
||||
array(
|
||||
'Name' => 'Mitch',
|
||||
)
|
||||
), $nonEmptyOnly);
|
||||
|
||||
// Filter by many including null, empty string, and non-empty
|
||||
$items1 = $list->filter('Email', array(null, '', 'damian@thefans.com'));
|
||||
$this->assertDOSEquals(array(
|
||||
array(
|
||||
'Name' => 'Damian',
|
||||
'Email' => 'damian@thefans.com',
|
||||
),
|
||||
array(
|
||||
'Name' => 'Stephen',
|
||||
),
|
||||
array(
|
||||
'Name' => 'Mitch',
|
||||
),
|
||||
array(
|
||||
'Name' => 'Hamish',
|
||||
)
|
||||
), $items1);
|
||||
|
||||
// Filter exclusion of above list
|
||||
$items2 = $list->filter('Email:not', array(null, '', 'damian@thefans.com'));
|
||||
$this->assertDOSEquals(array(
|
||||
array(
|
||||
'Name' => 'Richard',
|
||||
'Email' => 'richie@richers.com',
|
||||
),
|
||||
), $items2);
|
||||
|
||||
// Filter by many including empty string and non-empty
|
||||
$items3 = $list->filter('Email', array('', 'damian@thefans.com'));
|
||||
$this->assertDOSEquals(array(
|
||||
array(
|
||||
'Name' => 'Damian',
|
||||
'Email' => 'damian@thefans.com',
|
||||
),
|
||||
array(
|
||||
'Name' => 'Hamish',
|
||||
)
|
||||
), $items3);
|
||||
|
||||
// Filter by many including empty string and non-empty
|
||||
// This also relies no the workaround for null comparison as in the $nonEmptyOnly test
|
||||
$items4 = $list->filter('Email:not', array('', 'damian@thefans.com'));
|
||||
$this->assertDOSEquals(array(
|
||||
array(
|
||||
'Name' => 'Richard',
|
||||
'Email' => 'richie@richers.com',
|
||||
),
|
||||
array(
|
||||
'Name' => 'Stephen',
|
||||
),
|
||||
array(
|
||||
'Name' => 'Mitch',
|
||||
)
|
||||
), $items4);
|
||||
|
||||
// Filter by many including empty string and non-empty
|
||||
// The extra null check isn't necessary, but check that this doesn't fail
|
||||
$items5 = $list->filterAny(array(
|
||||
'Email:not' => array('', 'damian@thefans.com'),
|
||||
'Email' => null
|
||||
));
|
||||
$this->assertDOSEquals(array(
|
||||
array(
|
||||
'Name' => 'Richard',
|
||||
'Email' => 'richie@richers.com',
|
||||
),
|
||||
array(
|
||||
'Name' => 'Stephen',
|
||||
),
|
||||
array(
|
||||
'Name' => 'Mitch',
|
||||
)
|
||||
), $items5);
|
||||
|
||||
// Filter by null or empty values
|
||||
$items6 = $list->filter('Email', array(null, ''));
|
||||
$this->assertDOSEquals(array(
|
||||
array(
|
||||
'Name' => 'Stephen',
|
||||
),
|
||||
array(
|
||||
'Name' => 'Mitch',
|
||||
),
|
||||
array(
|
||||
'Name' => 'Hamish',
|
||||
)
|
||||
), $items6);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test null checks with case modifiers
|
||||
*/
|
||||
public function testFilterByNullCase() {
|
||||
// Test with case (case/nocase both use same code path)
|
||||
// Test with and without null, and with inclusion/exclusion permutations
|
||||
$list = DataObjectTest_Fan::get();
|
||||
|
||||
// Only an explicit NOT NULL should include null values
|
||||
$items6 = $list->filter('Email:not:case', array(null, '', 'damian@thefans.com'));
|
||||
$this->assertSQLContains(' AND "DataObjectTest_Fan"."Email" IS NOT NULL', $items6->sql());
|
||||
|
||||
// These should all include values where Email IS NULL
|
||||
$items7 = $list->filter('Email:nocase', array(null, '', 'damian@thefans.com'));
|
||||
$this->assertSQLContains(' OR "DataObjectTest_Fan"."Email" IS NULL', $items7->sql());
|
||||
$items8 = $list->filter('Email:not:case', array('', 'damian@thefans.com'));
|
||||
$this->assertSQLContains(' OR "DataObjectTest_Fan"."Email" IS NULL', $items8->sql());
|
||||
|
||||
// These should not contain any null checks at all
|
||||
$items9 = $list->filter('Email:nocase', array('', 'damian@thefans.com'));
|
||||
$this->assertSQLNotContains('"DataObjectTest_Fan"."Email" IS NULL', $items9->sql());
|
||||
$this->assertSQLNotContains('"DataObjectTest_Fan"."Email" IS NOT NULL', $items9->sql());
|
||||
}
|
||||
|
||||
/**
|
||||
* $list = $list->filterByCallback(function($item, $list) { return $item->Age == 21; })
|
||||
*/
|
||||
@ -865,7 +1039,8 @@ class DataListTest extends SapphireTest {
|
||||
|
||||
$sql = $list->sql($parameters);
|
||||
$this->assertSQLContains(
|
||||
'WHERE ("DataObjectTest_TeamComment"."Comment" = ?) AND (("DataObjectTest_TeamComment"."Name" != ?))',
|
||||
'WHERE ("DataObjectTest_TeamComment"."Comment" = ?) AND (("DataObjectTest_TeamComment"."Name" != ? '
|
||||
. 'OR "DataObjectTest_TeamComment"."Name" IS NULL))',
|
||||
$sql);
|
||||
$this->assertEquals(array('Phil is a unique guy, and comments on team2', 'Bob'), $parameters);
|
||||
}
|
||||
|
@ -1887,7 +1887,8 @@ class DataObjectTest_TeamComment extends DataObject implements TestOnly {
|
||||
class DataObjectTest_Fan extends DataObject implements TestOnly {
|
||||
|
||||
private static $db = array(
|
||||
'Name' => 'Varchar(255)'
|
||||
'Name' => 'Varchar(255)',
|
||||
'Email' => 'Varchar',
|
||||
);
|
||||
|
||||
private static $has_one = array(
|
||||
|
@ -62,6 +62,7 @@ DataObjectTest_TeamComment:
|
||||
DataObjectTest_Fan:
|
||||
fan1:
|
||||
Name: Damian
|
||||
Email: damian@thefans.com
|
||||
Favourite: =>DataObjectTest_Team.team1
|
||||
fan2:
|
||||
Name: Stephen
|
||||
@ -69,10 +70,14 @@ DataObjectTest_Fan:
|
||||
SecondFavourite: =>DataObjectTest_Team.team2
|
||||
fan3:
|
||||
Name: Richard
|
||||
Email: richie@richers.com
|
||||
Favourite: =>DataObjectTest_Team.team1
|
||||
fan4:
|
||||
Name: Mitch
|
||||
Favourite: =>DataObjectTest_SubTeam.subteam1
|
||||
fan5:
|
||||
Name: Hamish
|
||||
Email: ''
|
||||
DataObjectTest_Company:
|
||||
company1:
|
||||
Name: Company corp
|
||||
|
@ -17,8 +17,22 @@ class PolymorphicHasManyListTest extends SapphireTest {
|
||||
|
||||
protected $extraDataObjects = array(
|
||||
'DataObjectTest_Team',
|
||||
'DataObjectTest_Fixture',
|
||||
'DataObjectTest_SubTeam',
|
||||
'OtherSubclassWithSameField',
|
||||
'DataObjectTest_FieldlessTable',
|
||||
'DataObjectTest_FieldlessSubTable',
|
||||
'DataObjectTest_ValidatedObject',
|
||||
'DataObjectTest_Player',
|
||||
'DataObjectTest_TeamComment',
|
||||
'DataObjectTest_EquipmentCompany',
|
||||
'DataObjectTest_SubEquipmentCompany',
|
||||
'DataObjectTest\NamespacedClass',
|
||||
'DataObjectTest\RelationClass',
|
||||
'DataObjectTest_ExtendedTeamComment',
|
||||
'DataObjectTest_Company',
|
||||
'DataObjectTest_Staff',
|
||||
'DataObjectTest_CEO',
|
||||
'DataObjectTest_Fan',
|
||||
);
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user