Merge pull request #150 from silverstripe-terraformers/feature/complex-filtering

Add nested filtering via Criteria and Criterion objects.
This commit is contained in:
Robbie Averill 2018-08-22 12:00:03 +12:00 committed by GitHub
commit 1f156dfbb4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 1581 additions and 1 deletions

View File

@ -4,3 +4,8 @@ Name: fulltextsearchconfig
SilverStripe\ORM\DataObject:
extensions:
- SilverStripe\FullTextSearch\Search\Extensions\SearchUpdater_ObjectHandler
SilverStripe\Core\Injector\Injector:
SilverStripe\FullTextSearch\Search\Queries\SearchQuery:
calls:
- [ setHandler, [ %$SilverStripe\FullTextSearch\Search\Adapters\SolrSearchAdapter ]]

View File

@ -19,6 +19,7 @@
- [Searching value ranges](04_querying.md#searching-value-ranges)
- [Empty or existing values](04_querying.md#empty-or-existing-values)
- [Executing your query](04_querying.md#executing-your-query)
- [Complex filtering with Criteria](04_querying.md#complex-filtering-with-criteria)
- Advanced configuration
- [Facets](05_advanced_configuration.md#facets)
- [Using multiple indexes](05_advanced_configuration.md#multiple-indexes)

View File

@ -128,3 +128,164 @@ The return value of a `search()` call is an object which contains a few properti
* `Suggestion`: (optional) Any suggested spelling corrections in the original query notation
* `SuggestionNice`: (optional) Any suggested spelling corrections for display (without query notation)
* `SuggestionQueryString` (optional) Link to repeat the search with suggested spelling corrections
## Complex Filtering with Criteria
### Filtering related Objects
* `SearchCriteriaInterface`: Interface for `SearchCriterion` and `SearchCriteria` classes.
* `SearchCriterion`: An object containing a single field filter (target field, comparison value, comparison type).
* `SearchCriteria`: An object containing a collection of `SearchCriterion` and/or `SearchCriteria` with conjunctions (IE: `AND`, `OR`) between each.
* `SearchQueryWriter`: A class used to generate a query string based on a `SearchCriterion`.
* `SearchAdapterInterface`: An Interface for our SearchAdapters. This adapter will control what `SearchQueryWriter` is used for each `SearchCriteria`.
### General usage
We need 3 things to create a `SearchCriterion`:
* **`Target`**: EG the field in our Search Index that we want to filter against.
* **`Value`**: The value we want to use for comparison.
* **`Comparison`**: The type of comparison (EG: `EQUAL`, `IN`, etc).
All currently supported comparisons can be found as constants in `SearchCriterion`.
### Creating a new `SearchCriterion`
#### Method 1a and 1b
```php
// `EQUAL` is the default comparison for `SearchCriterion`, so no third param is required.
$criterion = new SearchCriterion('Product_Title', 'My Product');
// Or use the `create` static method.
$criterion = SearchCriterion::create('Product_Title', 'My Product');
```
### Creating a new `SearchCriteria`
`SearchCriteria` has a property called `$clauses` which is a collection of `SearchCriterion` (above) and/or `SearchCriteria` (allowing for infinite nesting of clauses), along with the conjunction used between each clause (IE: `AND`, `OR`). We want to build up our `SearchCriteria` by adding to it's `$clauses` collection.
`SearchCriteria` can either be passed an object that implements `SearchCriteriaInterface`, or it can be passed the `Target`, `Value`, and `Comparison` (like above).
#### Method 1
Instantiate a new `SearchCriteria` by providing an already instantiated `SearchCriterion` object. This `$criterion` will be added as the first item in the `$clauses` collection.
```php
$criteria = SearchCriteria::create($criterion);
```
#### Method 2
Instantiate a new `SearchCriteria` objects and define the `Target`, `Value`, and `Comparison`. `SearchCriteria` will create a new `SearchCriterion` object based on the values, and add it to the `$clauses` collection.
```php
$criteria = SearchCriteria::create('Product_CatID', array(21, 24, 25), AbstractCriterion::IN);
```
### Adding additional `SearchCriterion` to our `SearchCriteria`
When you want to add more complexity to your `SearchCriteria`, there are two methods available:
* `addAnd`: Add a new `SearchCriterion` or `SearchCriteria` with an `AND` conjunction.
* `addOr`: Add a new `SearchCriterion` or `SearchCriteria` with an `OR` conjunction.
#### Method 1
Use method chaining to create a `SearchCriterion` with two clauses.
```php
// Filter by products with stock that are in either of these 3 categories.
$criteria = SearchCriteria::create('Product_CatID', array(21, 24, 25), AbstractCriterion::IN)
->addAnd('Product_Stock', 0, AbstractSearchCriterion::GREATER_THAN);
```
#### Method 2
Systematically add clauses to your already instantiated `SearchCriteria`.
```php
// Filter by products in either of these 3 categories.
$criteria = SearchCriteria::create('Product_CatID', array(21, 24, 25), AbstractCriterion::IN);
... other stuff
// Filter by products with stock.
$criteria->addAnd('Product_StockLevel', 0, AbstractCriterion::GREATER_THAN);
```
### Adding multiple levels of filtering to our `SearchCriteria`
`SearchCriteria` also allows you to pass in other `SearchCriteria` objects as you instantiate it and as you use the `addAnd` and `addOr` methods.
```php
// Filter by products that are in either of these 3 categories with stock.
$stockCategoryCriteria = SearchCriteria::create('Product_CatID', array(21, 24, 25), AbstractCriterion::IN)
->addAnd('Product_Stock', 0, AbstractSearchCriterion::GREATER_THAN);
// Filter by products in Category ID 1 with stock over 5.
$legoCriteria = SearchCriteria::create('Product_CatID', 1, AbstractCriterion::EQUAL)
->addAnd('Product_Stock', 5, AbstractSearchCriterion::GREATER_THAN);
// Combine the two criteria with an `OR` conjunction
$criteria = SearchCriteria::create($stockCategoryCriteria)
->addOr($legoCriteria);
```
### Adding `SearchCriteria` to our `SearchQuery`
Our `SearchQuery` class now has a property called `$criteria` which holds all of our `SearchCriteria`. You can add new `SearchCriteria` by using `SearchQuery::filterBy()`.
#### Method 1
Pass in an already instantiated `SearchCriteria` object. If you implemented complex filtering (above), you will probably need to follow this method - fully creating your `SearchCriteria` first, and then passing it to the `SearchQuery`.
```php
$query->filterBy($criteria);
```
#### Method 2a
Where basic (single level) filtering is ok, the `SearchQuery::filterBy()` method can be used to create your `SearchCriterion` and `SearchCriteria` object.
```php
$query->filterBy('Product_CatID', array(21, 24, 25), AbstractCriterion::IN);
```
#### Method 2b
The `filterBy()` method will return the **current** `SearchCriteria`, this allows you to method chain the `addAnd` and `addOr` methods.
```php
// Filter by products with stock that are in either of these 3 categories.
$searchQuery->filterBy('Product_CategoryID', array(21, 24, 25), AbstractCriterion::IN)
->addAnd('Product_StockLevel', 0, AbstractCriterion::GREATER_THAN);
```
Each item in the `$criteria` collection are treated with an `AND` conjunction (matching current `filter`/`exclude` functionality).
### Search Query Writers
Provided are 3 different `SearchQueryWriter`s for Solr:
* `SolrSearchQueryWriter_Basic`
* `SolrSearchQueryWriter_In`
* `SolrSearchQueryWriter_Range`
When these Writers are provided a `SearchCriterion`, they will generate the desired query string.
### Search Adapters
Search Adapters need to provide the following information:
* What is the search engine's conjunction strings? (EG: are they "AND" and "OR", or are they "&&" and "||", etc).
* What is the desired comparison container string? (EG: "**+(** query here **)**") for Solr).
* Most importantly - how to generate the query string from a `SearchCriterion`.
The `SolrSearchAdapter` uses `SearchQueryWriter`s (above) to generate query strings from a `SearchCriterion`.
### Customising your `SearchCriterion`/`SearchQueryWriter`
If you find that you do not want your `SearchCriterion` being parsed by one of the default `SearchQueryWriter`s (for whatever reason), you can optionally pass your own `SearchQueryWriter` to your `SearchCriterion` either as the **fourth parameter** when instantiating it, or by calling `setSearchQueryWriter()`.
If this value is set, then the (default Solr) Adapter will always use the provided `SearchQueryWriter`, rather than deciding for itself.
This should allow you to have full control over how your query strings are being generated if the default `SearchQueryWriter`s are not cutting it for you.

View File

@ -0,0 +1,56 @@
<?php
namespace SilverStripe\FullTextSearch\Search\Adapters;
use SilverStripe\FullTextSearch\Search\Criteria\SearchCriterion;
/**
* Interface SearchAdapterInterface
* @package SilverStripe\FullTextSearch\Adapters
*/
interface SearchAdapterInterface
{
/**
* Parameter $conjunction should be CONJUNCTION_AND or CONJUNCTION_OR, and your Adapter should return the
* appropriate string representation of that conjunction.
*
* @param string $conjunction
* @return string
*/
public function getConjunctionFor($conjunction);
/**
* Due to the fact that we have filter criteria coming from legacy methods (as well as our Criteria), you may find
* that you need to prepend (or append) something to your group of Criteria statements.
*
* EG: For Solr, we need to add a "+" between the default filters, and our Criteria.
*
* @return string
*/
public function getPrependToCriteriaComponent();
/**
* @return string
*/
public function getAppendToCriteriaComponent();
/**
* Define how each of your comparisons should be contained.
*
* EG: For Solr, we wrap each comparison in ().
*
* @return string
*/
public function getOpenComparisonContainer();
/**
* @return string
*/
public function getCloseComparisonContainer();
/**
* @param SearchCriterion $criterion
* @return string
*/
public function generateQueryString(SearchCriterion $criterion);
}

View File

@ -0,0 +1,115 @@
<?php
namespace SilverStripe\FullTextSearch\Search\Adapters;
use SilverStripe\FullTextSearch\Search\Criteria\SearchCriteria;
use SilverStripe\FullTextSearch\Search\Criteria\SearchCriterion;
use SilverStripe\FullTextSearch\Search\Queries\AbstractSearchQueryWriter;
use SilverStripe\FullTextSearch\Solr\Writers\SolrSearchQueryWriterBasic;
use SilverStripe\FullTextSearch\Solr\Writers\SolrSearchQueryWriterIn;
use SilverStripe\FullTextSearch\Solr\Writers\SolrSearchQueryWriterRange;
use InvalidArgumentException;
/**
* Class SolrSearchAdapter
* @package SilverStripe\FullTextSearch\Search\Adapters
*/
class SolrSearchAdapter implements SearchAdapterInterface
{
/**
* @param SearchCriterion $criterion
* @return string
* @throws InvalidArgumentException
*/
public function generateQueryString(SearchCriterion $criterion)
{
$writer = $this->getSearchQueryWriter($criterion);
return $writer->generateQueryString($criterion);
}
/**
* @param string $conjunction
* @return string
* @throws InvalidArgumentException
*/
public function getConjunctionFor($conjunction)
{
switch ($conjunction) {
case SearchCriteria::CONJUNCTION_AND:
case SearchCriteria::CONJUNCTION_OR:
return sprintf(' %s ', $conjunction);
default:
throw new InvalidArgumentException(
sprintf('Invalid conjunction supplied to SolrSearchAdapter: "%s".', $conjunction)
);
}
}
/**
* @return string
*/
public function getPrependToCriteriaComponent()
{
return '+';
}
/**
* @return string
*/
public function getAppendToCriteriaComponent()
{
return '';
}
/**
* @return string
*/
public function getOpenComparisonContainer()
{
return '(';
}
/**
* @return string
*/
public function getCloseComparisonContainer()
{
return ')';
}
/**
* @param SearchCriterion $searchCriterion
* @return AbstractSearchQueryWriter
* @throws InvalidArgumentException
*/
protected function getSearchQueryWriter(SearchCriterion $searchCriterion)
{
if ($searchCriterion->getSearchQueryWriter() instanceof AbstractSearchQueryWriter) {
// The user has defined their own SearchQueryWriter, so we should just return it.
return $searchCriterion->getSearchQueryWriter();
}
switch ($searchCriterion->getComparison()) {
case SearchCriterion::EQUAL:
case SearchCriterion::NOT_EQUAL:
return SolrSearchQueryWriterBasic::create();
case SearchCriterion::IN:
case SearchCriterion::NOT_IN:
return SolrSearchQueryWriterIn::create();
case SearchCriterion::GREATER_EQUAL:
case SearchCriterion::GREATER_THAN:
case SearchCriterion::LESS_EQUAL:
case SearchCriterion::LESS_THAN:
case SearchCriterion::ISNULL:
case SearchCriterion::ISNOTNULL:
return SolrSearchQueryWriterRange::create();
case SearchCriterion::CUSTOM:
// CUSTOM requires a SearchQueryWriter be provided. One can't have been provided, or it would have been
// picked up at the top of the method.
throw new InvalidArgumentException('SearchQueryWriter undefined or unsupported in SearchCriterion');
default:
throw new InvalidArgumentException('Unsupported comparison type in SolrSearchAdapter');
}
}
}

View File

@ -0,0 +1,239 @@
<?php
namespace SilverStripe\FullTextSearch\Search\Criteria;
use SilverStripe\FullTextSearch\Search\Adapters\SearchAdapterInterface;
use SilverStripe\FullTextSearch\Search\Queries\AbstractSearchQueryWriter;
/**
* Class SearchCriteria
* @package SilverStripe\FullTextSearch\Criteria
*/
class SearchCriteria implements SearchCriteriaInterface
{
/**
* @param string
*/
const CONJUNCTION_AND = 'AND';
/**
* @param string
*/
const CONJUNCTION_OR = 'OR';
/**
* A collection of SearchCriterion and SearchCriteria.
*
* @var SearchCriteriaInterface[]
*/
protected $clauses = array();
/**
* The conjunctions used between Criteria (AND/OR).
*
* @var string[]
*/
protected $conjunctions = array();
/**
* @var SearchAdapterInterface|null
*/
protected $adapter = null;
/**
* You can pass through a string value, Criteria object, or Criterion object for $target.
*
* String value might be "SiteTree_Title" or whatever field in your index that you're trying to target.
*
* If you require complex filtering then you can build your Criteria object first with multiple layers/levels of
* Criteria, and then pass it in here when you're ready.
*
* If you have your own Criterion object that you've created that you want to use, you can also pass that in here.
*
* @param string|SearchCriterion $target
* @param mixed $value
* @param string|null $comparison
* @param AbstractSearchQueryWriter $searchQueryWriter
*/
public function __construct(
$target,
$value = null,
$comparison = null,
AbstractSearchQueryWriter $searchQueryWriter = null
) {
$this->addClause($this->getCriterionForCondition($target, $value, $comparison, $searchQueryWriter));
}
/**
* Static create method provided so that you can perform method chaining.
*
* @param $target
* @param null $value
* @param null $comparison
* @param AbstractSearchQueryWriter $searchQueryWriter
* @return SearchCriteria
*/
public static function create(
$target,
$value = null,
$comparison = null,
AbstractSearchQueryWriter $searchQueryWriter = null
) {
return new SearchCriteria($target, $value, $comparison, $searchQueryWriter);
}
/**
* @return null|SearchAdapterInterface
*/
public function getAdapter()
{
return $this->adapter;
}
/**
* @param SearchAdapterInterface $adapter
* @return $this
*/
public function setAdapter(SearchAdapterInterface $adapter)
{
$this->adapter = $adapter;
return $this;
}
/**
* @param string $ps Current prepared statement.
* @return void
* @throws \Exception
*/
public function appendPreparedStatementTo(&$ps)
{
$adapter = $this->getAdapter();
if (!$adapter instanceof SearchAdapterInterface) {
throw new \Exception('No adapter has been applied to SearchCriteria');
}
$ps .= $adapter->getOpenComparisonContainer();
foreach ($this->getClauses() as $key => $clause) {
$clause->setAdapter($adapter);
$clause->appendPreparedStatementTo($ps);
// There's always one less conjunction then there are clauses.
if ($this->getConjunction($key) !== null) {
$ps .= $adapter->getConjunctionFor($this->getConjunction($key));
}
}
$ps .= $adapter->getCloseComparisonContainer();
}
/**
* @param string|SearchCriteriaInterface $target
* @param mixed $value
* @param string|null $comparison
* @param AbstractSearchQueryWriter $searchQueryWriter
* @return $this
*/
public function addAnd(
$target,
$value = null,
$comparison = null,
AbstractSearchQueryWriter $searchQueryWriter = null
) {
$criterion = $this->getCriterionForCondition($target, $value, $comparison, $searchQueryWriter);
$this->addConjunction(SearchCriteria::CONJUNCTION_AND);
$this->addClause($criterion);
return $this;
}
/**
* @param string|SearchCriteriaInterface $target
* @param mixed $value
* @param string|null $comparison
* @param AbstractSearchQueryWriter $searchQueryWriter
* @return $this
*/
public function addOr(
$target,
$value = null,
$comparison = null,
AbstractSearchQueryWriter $searchQueryWriter = null
) {
$criterion = $this->getCriterionForCondition($target, $value, $comparison, $searchQueryWriter);
$this->addConjunction(SearchCriteria::CONJUNCTION_OR);
$this->addClause($criterion);
return $this;
}
/**
* @param string|SearchCriteriaInterface $target
* @param mixed $value
* @param string $comparison
* @param AbstractSearchQueryWriter $searchQueryWriter
* @return SearchCriteriaInterface
*/
protected function getCriterionForCondition(
$target,
$value,
$comparison,
AbstractSearchQueryWriter $searchQueryWriter = null
) {
if ($target instanceof SearchCriteriaInterface) {
return $target;
}
return new SearchCriterion($target, $value, $comparison, $searchQueryWriter);
}
/**
* @return SearchCriteriaInterface[]
*/
protected function getClauses()
{
return $this->clauses;
}
/**
* @param SearchCriteriaInterface $criterion
*/
protected function addClause($criterion)
{
$this->clauses[] = $criterion;
}
/**
* @return string[]
*/
protected function getConjunctions()
{
return $this->conjunctions;
}
/**
* @param int $key
* @return string|null
*/
protected function getConjunction($key)
{
$conjunctions = $this->getConjunctions();
if (!array_key_exists($key, $conjunctions)) {
return null;
}
return $conjunctions[$key];
}
/**
* @param string $conjunction
*/
protected function addConjunction($conjunction)
{
$this->conjunctions[] = $conjunction;
}
}

View File

@ -0,0 +1,36 @@
<?php
namespace SilverStripe\FullTextSearch\Search\Criteria;
use SilverStripe\FullTextSearch\Search\Adapters\SearchAdapterInterface;
/**
* Interface SearchCriteriaInterface
*
* SearchCriteria and SearchCriterion objects must implement this interface.
*/
interface SearchCriteriaInterface
{
/**
* The method used in all SearchCriterion to generate and append their filter query statements.
*
* This is also used in SearchCriteria to loop through it's collected SearchCriterion and append the above. This
* allows us to have SearchCriteria and SearchCriterion in the same collections (allowing us to have complex nested
* filtering).
*
* @param $ps
* @return void
*/
public function appendPreparedStatementTo(&$ps);
/**
* @return SearchAdapterInterface
*/
public function getAdapter();
/**
* @param SearchAdapterInterface $adapter
* @return $this
*/
public function setAdapter(SearchAdapterInterface $adapter);
}

View File

@ -0,0 +1,269 @@
<?php
namespace SilverStripe\FullTextSearch\Search\Criteria;
use SilverStripe\Core\Injector\Injectable;
use SilverStripe\FullTextSearch\Search\Adapters\SearchAdapterInterface;
use SilverStripe\FullTextSearch\Search\Queries\AbstractSearchQueryWriter;
/**
* Class SearchCriterion
* @package SilverStripe\FullTextSearch\Search\Criteria
*/
class SearchCriterion implements SearchCriteriaInterface
{
use Injectable;
/**
* field:value
*
* @var string
*/
const EQUAL = 'EQUAL';
/**
* -field:value
*
* @var string
*/
const NOT_EQUAL = 'NOT_EQUAL';
/**
* field:[value TO *]
*
* @var string
*/
const GREATER_EQUAL = 'GREATER_EQUAL';
/**
* field:{value TO *}
*
* @var string
*/
const GREATER_THAN = 'GREATER_THAN';
/**
* field:[* TO value]
*
* @var string
*/
const LESS_EQUAL = 'LESS_EQUAL';
/**
* field:{* TO value}
*
* @var string
*/
const LESS_THAN = 'LESS_THAN';
/**
* (field:value1 field:value2 field:value3)
*
* @var string
*/
const IN = 'IN';
/**
* -(field:value1 field:value2 field:value3)
*
* @var string
*/
const NOT_IN = 'NOT_IN';
/**
* field:[* TO *]
*
* @var string
*/
const ISNULL = 'ISNULL';
/**
* -field:[* TO *]
*
* @var string
*/
const ISNOTNULL = 'ISNOTNULL';
/**
* A custom Criterion with it's own SearchQueryWriter
*
* @var string
*/
const CUSTOM = 'CUSTOM';
/**
* @var string
*/
protected $comparison;
/**
* The table and field that this Criterion is applied to.
*
* @var string
*/
protected $target;
/**
* @var mixed
*/
protected $value;
/**
* @var SearchAdapterInterface
*/
protected $adapter;
/**
* @var AbstractSearchQueryWriter
*/
protected $searchQueryWriter;
/**
* @param string $target
* @param string|array $value
* @param string|null $comparison
* @param AbstractSearchQueryWriter $searchQueryWriter
*/
public function __construct(
$target,
$value,
$comparison = null,
AbstractSearchQueryWriter $searchQueryWriter = null
) {
// EQUAL is our default comparison.
if ($comparison === null) {
$comparison = SearchCriterion::EQUAL;
}
$this->setTarget($target);
$this->setValue($value);
$this->setComparison($comparison);
$this->setSearchQueryWriter($searchQueryWriter);
}
/**
* @return SearchAdapterInterface
*/
public function getAdapter()
{
return $this->adapter;
}
/**
* @param SearchAdapterInterface $adapter
* @return $this
*/
public function setAdapter(SearchAdapterInterface $adapter)
{
$this->adapter = $adapter;
return $this;
}
/**
* @param string $ps
* @return void
* @throws \Exception
*/
public function appendPreparedStatementTo(&$ps)
{
$adapter = $this->getAdapter();
if (!$adapter instanceof SearchAdapterInterface) {
throw new \Exception('No adapter has been applied to SearchCriteria');
}
$ps .= $adapter->generateQueryString($this);
}
/**
* String values should be passed into our filter string with quotation marks and escaping.
*
* @param string $value
* @return string
*/
public function getQuoteValue($value)
{
if (is_string($value)) {
return sprintf('"%s"', $value);
}
return $value;
}
/**
* @return AbstractSearchQueryWriter
*/
public function getSearchQueryWriter()
{
return $this->searchQueryWriter;
}
/**
* @param AbstractSearchQueryWriter $searchQueryWriter
* @return $this
*/
public function setSearchQueryWriter($searchQueryWriter)
{
$this->searchQueryWriter = $searchQueryWriter;
return $this;
}
/**
* @return string
*/
public function getComparison()
{
return $this->comparison;
}
/**
* @param string|null $comparison
* @return $this
*/
protected function setComparison($comparison)
{
$this->comparison = $comparison;
return $this;
}
/**
* @return string
*/
public function getTarget()
{
return $this->target;
}
/**
* @param string $target
* @return $this
*/
protected function setTarget($target)
{
$this->target = $target;
return $this;
}
/**
* @return string|array
*/
public function getValue()
{
return $this->value;
}
/**
* @param string|array $value
* @return $this
*/
protected function setValue($value)
{
$this->value = $value;
return $this;
}
}

View File

@ -0,0 +1,21 @@
<?php
namespace SilverStripe\FullTextSearch\Search\Queries;
use SilverStripe\Core\Injector\Injectable;
use SilverStripe\FullTextSearch\Search\Criteria\SearchCriterion;
/**
* Class AbstractSearchQueryWriter
* @package SilverStripe\FullTextSearch\Search\Queries
*/
abstract class AbstractSearchQueryWriter
{
use Injectable;
/**
* @param SearchCriterion $searchCriterion
* @return string
*/
abstract public function generateQueryString(SearchCriterion $searchCriterion);
}

View File

@ -3,6 +3,9 @@
namespace SilverStripe\FullTextSearch\Search\Queries;
use SilverStripe\Dev\Deprecation;
use SilverStripe\FullTextSearch\Search\Adapters\SearchAdapterInterface;
use SilverStripe\FullTextSearch\Search\Criteria\SearchCriteria;
use SilverStripe\FullTextSearch\Search\Criteria\SearchCriteriaInterface;
use SilverStripe\View\ViewableData;
use stdClass;
@ -27,11 +30,25 @@ class SearchQuery extends ViewableData
public $require = [];
public $exclude = [];
/**
* @var SearchCriteriaInterface[]
*/
public $criteria = [];
protected $start = 0;
protected $limit = -1;
/**
* @var SearchAdapterInterface
*/
protected $adapter = null;
/** These are the API functions */
/**
* SearchQuery constructor.
* @throws \Psr\Container\NotFoundExceptionInterface
*/
public function __construct()
{
if (self::$missing === null) {
@ -42,12 +59,24 @@ class SearchQuery extends ViewableData
}
}
/**
* @param SearchAdapterInterface $adapter
* @return SearchQuery
*/
public function setHandler(SearchAdapterInterface $adapter)
{
$this->adapter = $adapter;
return $this;
}
/**
* @param string $text Search terms. Exact format (grouping, boolean expressions, etc.) depends on
* the search implementation.
* @param array $fields Limits the search to specific fields (using composite field names)
* @param array $boost Map of composite field names to float values. The higher the value,
* the more important the field gets for relevancy.
* @return $this
*/
public function addSearchTerm($text, $fields = null, $boost = [])
{
@ -68,6 +97,7 @@ class SearchQuery extends ViewableData
* @param string $text See {@link addSearchTerm()}
* @param array $fields See {@link addSearchTerm()}
* @param array $boost See {@link addSearchTerm()}
* @return $this
*/
public function addFuzzySearchTerm($text, $fields = null, $boost = [])
{
@ -116,6 +146,7 @@ class SearchQuery extends ViewableData
*
* @param string $field Composite name of the field
* @param mixed $values Scalar value, array of values, or an instance of SearchQuery_Range
* @return $this
*/
public function addFilter($field, $values)
{
@ -138,6 +169,7 @@ class SearchQuery extends ViewableData
*
* @param string $field
* @param mixed $values
* @return $this
*/
public function addExclude($field, $values)
{
@ -155,6 +187,37 @@ class SearchQuery extends ViewableData
return $this->exclude;
}
/**
* You can pass through a string value, Criteria object, or Criterion object for $target.
*
* String value might be "SiteTree_Title" or whatever field in your index that you're trying to target.
*
* If you require complex filtering then you can build your Criteria object first with multiple layers/levels of
* Criteria, and then pass it in here when you're ready.
*
* If you have your own Criterion object that you've created that you want to use, you can also pass that in here.
*
* @param string|SearchCriteriaInterface $target
* @param mixed $value
* @param string|null $comparison
* @param AbstractSearchQueryWriter $searchQueryWriter
* @return SearchCriteriaInterface
*/
public function filterBy(
$target,
$value = null,
$comparison = null,
AbstractSearchQueryWriter $searchQueryWriter = null
) {
if (!$target instanceof SearchCriteriaInterface) {
$target = new SearchCriteria($target, $value, $comparison, $searchQueryWriter);
}
$this->addCriteria($target);
return $target;
}
public function setStart($start)
{
$this->start = $start;
@ -206,6 +269,14 @@ class SearchQuery extends ViewableData
return $this->search || $this->classes || $this->require || $this->exclude;
}
/**
* @return SearchAdapterInterface
*/
public function getAdapter()
{
return $this->adapter;
}
public function __toString()
{
return "Search Query\n";
@ -290,4 +361,32 @@ class SearchQuery extends ViewableData
Deprecation::notice('4.0', 'Use setPageSize() instead');
return $this->setPageSize($page);
}
/**
* @return SearchCriteriaInterface[]
*/
public function getCriteria()
{
return $this->criteria;
}
/**
* @param SearchCriteriaInterface[] $criteria
* @return SearchQuery
*/
public function setCriteria($criteria)
{
$this->criteria = $criteria;
return $this;
}
/**
* @param SearchCriteriaInterface $criteria
* @return SearchQuery
*/
public function addCriteria($criteria)
{
$this->criteria[] = $criteria;
return $this;
}
}

View File

@ -632,6 +632,8 @@ abstract class SolrIndex extends SearchIndex
static::warn($e);
return false;
}
return true;
}
/**
@ -643,6 +645,7 @@ abstract class SolrIndex extends SearchIndex
*
* @param array $classes List of non-obsolete classes in the same format as SolrIndex::getClasses()
* @return bool Flag if successful
* @throws \Apache_Solr_HttpTransportException
*/
public function clearObsoleteClasses($classes)
{
@ -676,6 +679,8 @@ abstract class SolrIndex extends SearchIndex
static::warn($e);
return false;
}
return true;
}
/**
@ -685,6 +690,8 @@ abstract class SolrIndex extends SearchIndex
* @param array $params Extra request parameters passed through to Solr
* @return ArrayData Map with the following keys:
* - 'Matches': ArrayList of the matched object instances
* @throws \Apache_Solr_HttpTransportException
* @throws \Apache_Solr_InvalidArgumentException
*/
public function search(SearchQuery $query, $offset = -1, $limit = -1, $params = array())
{
@ -989,18 +996,57 @@ abstract class SolrIndex extends SearchIndex
return $fq;
}
/**
* @param SearchQuery $searchQuery
* @return string
* @throws \Exception
*/
protected function getCriteriaComponent(SearchQuery $searchQuery)
{
if (count($searchQuery->getCriteria()) === 0) {
return null;
}
if ($searchQuery->getAdapter() === null) {
throw new \Exception('SearchQuery does not have a SearchAdapter applied');
}
// Need to start with a positive conjunction.
$ps = $searchQuery->getAdapter()->getPrependToCriteriaComponent();
foreach ($searchQuery->getCriteria() as $clause) {
$clause->setAdapter($searchQuery->getAdapter());
$clause->appendPreparedStatementTo($ps);
}
// Need to start with a positive conjunction.
$ps .= $searchQuery->getAdapter()->getAppendToCriteriaComponent();
// Returned as an array because that's how `getFiltersComponent` expects it.
return $ps;
}
/**
* Get all filter conditions for this search
*
* @param SearchQuery $searchQuery
* @return array
* @throws \Exception
*/
public function getFiltersComponent(SearchQuery $searchQuery)
{
return array_merge(
$criteriaComponent = $this->getCriteriaComponent($searchQuery);
$components = array_merge(
$this->getRequireFiltersComponent($searchQuery),
$this->getExcludeFiltersComponent($searchQuery)
);
if ($criteriaComponent !== null) {
$components[] = $criteriaComponent;
}
return $components;
}
protected $service;

View File

@ -0,0 +1,54 @@
<?php
namespace SilverStripe\FullTextSearch\Solr\Writers;
use SilverStripe\FullTextSearch\Search\Criteria\SearchCriterion;
use SilverStripe\FullTextSearch\Search\Queries\AbstractSearchQueryWriter;
/**
* Class SolrSearchQueryWriter_Basic
* @package SilverStripe\FullTextSearch\Solr\Writers
*/
class SolrSearchQueryWriterBasic extends AbstractSearchQueryWriter
{
/**
* @var SearchCriterion $searchCriterion
* @return string
*/
public function generateQueryString(SearchCriterion $searchCriterion)
{
return sprintf(
'%s(%s%s%s)',
$this->getComparisonPolarity($searchCriterion->getComparison()),
addslashes($searchCriterion->getTarget()),
$this->getComparisonConjunction(),
$searchCriterion->getQuoteValue($searchCriterion->getValue())
);
}
/**
* Is this a positive (+) or negative (-) Solr comparison.
*
* @param string $comparison
* @return string
*/
protected function getComparisonPolarity($comparison)
{
switch ($comparison) {
case SearchCriterion::NOT_EQUAL:
return '-';
default:
return '+';
}
}
/**
* Decide how we are comparing our left and right values.
*
* @return string
*/
protected function getComparisonConjunction()
{
return ':';
}
}

View File

@ -0,0 +1,86 @@
<?php
namespace SilverStripe\FullTextSearch\Solr\Writers;
use InvalidArgumentException;
use SilverStripe\FullTextSearch\Search\Criteria\SearchCriterion;
use SilverStripe\FullTextSearch\Search\Queries\AbstractSearchQueryWriter;
/**
* Class SolrSearchQueryWriter_In
* @package SilverStripe\FullTextSearch\Solr\Writers
*/
class SolrSearchQueryWriterIn extends AbstractSearchQueryWriter
{
/**
* @param SearchCriterion $searchCriterion
* @return string
*/
public function generateQueryString(SearchCriterion $searchCriterion)
{
return sprintf(
'%s%s',
$this->getComparisonPolarity($searchCriterion->getComparison()),
$this->getInComparisonString($searchCriterion)
);
}
/**
* Is this a positive (+) or negative (-) Solr comparison.
*
* @param string $comparison
* @return string
*/
protected function getComparisonPolarity($comparison)
{
switch ($comparison) {
case SearchCriterion::NOT_IN:
return '-';
default:
return '+';
}
}
/**
* @param SearchCriterion $searchCriterion
* @return string
* @throws InvalidArgumentException
*/
protected function getInComparisonString(SearchCriterion $searchCriterion)
{
$conditions = array();
if (!is_array($searchCriterion->getValue())) {
throw new InvalidArgumentException('Invalid value type for Criterion IN');
}
foreach ($searchCriterion->getValue() as $value) {
if (is_string($value)) {
// String values need to be wrapped in quotes and escaped.
$value = $searchCriterion->getQuoteValue($value);
}
$conditions[] = sprintf(
'%s%s%s',
addslashes($searchCriterion->getTarget()),
$this->getComparisonConjunction(),
$value
);
}
return sprintf(
'(%s)',
implode(' ', $conditions)
);
}
/**
* Decide how we are comparing our left and right values.
*
* @return string
*/
protected function getComparisonConjunction()
{
return ':';
}
}

View File

@ -0,0 +1,150 @@
<?php
namespace SilverStripe\FullTextSearch\Solr\Writers;
use InvalidArgumentException;
use SilverStripe\FullTextSearch\Search\Criteria\SearchCriterion;
use SilverStripe\FullTextSearch\Search\Queries\AbstractSearchQueryWriter;
/**
* Class SolrSearchQueryWriter_Range
* @package SilverStripe\FullTextSearch\Solr\Writers
*/
class SolrSearchQueryWriterRange extends AbstractSearchQueryWriter
{
/**
* @param SearchCriterion $searchCriterion
* @return string
*/
public function generateQueryString(SearchCriterion $searchCriterion)
{
return sprintf(
'%s(%s:%s%s%s%s%s)',
$this->getComparisonPolarity($searchCriterion->getComparison()),
addslashes($searchCriterion->getTarget()),
$this->getOpenComparisonContainer($searchCriterion->getComparison()),
$this->getLeftComparison($searchCriterion),
$this->getComparisonConjunction(),
$this->getRightComparison($searchCriterion),
$this->getCloseComparisonContainer($searchCriterion->getComparison())
);
}
/**
* Is this a positive (+) or negative (-) Solr comparison.
*
* @param string $comparison
* @return string
*/
protected function getComparisonPolarity($comparison)
{
switch ($comparison) {
case SearchCriterion::ISNULL:
return '-';
default:
return '+';
}
}
/**
* Select the value that we want as our left comparison value.
*
* @param SearchCriterion $searchCriterion
* @return mixed|string
* @throws InvalidArgumentException
*/
protected function getLeftComparison(SearchCriterion $searchCriterion)
{
switch ($searchCriterion->getComparison()) {
case SearchCriterion::GREATER_EQUAL:
case SearchCriterion::GREATER_THAN:
return $searchCriterion->getValue();
case SearchCriterion::ISNULL:
case SearchCriterion::ISNOTNULL:
case SearchCriterion::LESS_EQUAL:
case SearchCriterion::LESS_THAN:
return '*';
default:
throw new InvalidArgumentException('Invalid comparison for RangeCriterion');
}
}
/**
* Select the value that we want as our right comparison value.
*
* @param SearchCriterion $searchCriterion
* @return mixed|string
* @throws InvalidArgumentException
*/
protected function getRightComparison(SearchCriterion $searchCriterion)
{
switch ($searchCriterion->getComparison()) {
case SearchCriterion::GREATER_EQUAL:
case SearchCriterion::GREATER_THAN:
case SearchCriterion::ISNULL:
case SearchCriterion::ISNOTNULL:
return '*';
case SearchCriterion::LESS_EQUAL:
case SearchCriterion::LESS_THAN:
return $searchCriterion->getValue();
default:
throw new InvalidArgumentException('Invalid comparison for RangeCriterion');
}
}
/**
* Decide how we are comparing our left and right values.
*
* @return string
*/
protected function getComparisonConjunction()
{
return ' TO ';
}
/**
* Does our comparison need a container? EG: "[* TO *]"? If so, return the opening container brace.
*
* @param string $comparison
* @return string
* @throws InvalidArgumentException
*/
protected function getOpenComparisonContainer($comparison)
{
switch ($comparison) {
case SearchCriterion::GREATER_EQUAL:
case SearchCriterion::LESS_EQUAL:
case SearchCriterion::ISNULL:
case SearchCriterion::ISNOTNULL:
return '[';
case SearchCriterion::GREATER_THAN:
case SearchCriterion::LESS_THAN:
return '{';
default:
throw new InvalidArgumentException('Invalid comparison for RangeCriterion');
}
}
/**
* Does our comparison need a container? EG: "[* TO *]"? If so, return the closing container brace.
*
* @param string $comparison
* @return string
* @throws InvalidArgumentException
*/
protected function getCloseComparisonContainer($comparison)
{
switch ($comparison) {
case SearchCriterion::GREATER_EQUAL:
case SearchCriterion::LESS_EQUAL:
case SearchCriterion::ISNULL:
case SearchCriterion::ISNOTNULL:
return ']';
case SearchCriterion::GREATER_THAN:
case SearchCriterion::LESS_THAN:
return '}';
default:
throw new InvalidArgumentException('Invalid comparison for RangeCriterion');
}
}
}

242
tests/SolrWritersTest.php Normal file
View File

@ -0,0 +1,242 @@
<?php
namespace SilverStripe\FullTextSearch\Tests;
use \InvalidArgumentException;
use SilverStripe\Dev\SapphireTest;
use SilverStripe\FullTextSearch\Search\Adapters\SolrSearchAdapter;
use SilverStripe\FullTextSearch\Search\Criteria\SearchCriteria;
use SilverStripe\FullTextSearch\Search\Criteria\SearchCriterion;
use SilverStripe\FullTextSearch\Search\Queries\SearchQuery;
use SilverStripe\FullTextSearch\Solr\Writers\SolrSearchQueryWriterBasic;
use SilverStripe\FullTextSearch\Solr\Writers\SolrSearchQueryWriterIn;
use SilverStripe\FullTextSearch\Solr\Writers\SolrSearchQueryWriterRange;
use SilverStripe\FullTextSearch\Tests\SolrIndexTest\SolrIndexTest_FakeIndex;
/**
* Class SolrWritersTest
* @package SilverStripe\FullTextSearch\Tests
*/
class SolrWritersTest extends SapphireTest
{
public function testBasicEqualQueryString()
{
$criteria = new SearchCriterion('Title', 'Test', SearchCriterion::EQUAL);
$writer = SolrSearchQueryWriterBasic::create();
$expected = '+(Title:"Test")';
$this->assertEquals($expected, $writer->generateQueryString($criteria));
}
public function testBasicNotEqualQueryString()
{
$criteria = new SearchCriterion('Title', 'Test', SearchCriterion::NOT_EQUAL);
$writer = SolrSearchQueryWriterBasic::create();
$expected = '-(Title:"Test")';
$this->assertEquals($expected, $writer->generateQueryString($criteria));
}
public function testBasicInQueryString()
{
$criteria = new SearchCriterion('ID', [1,2,3], SearchCriterion::IN);
$writer = SolrSearchQueryWriterIn::create();
$expected = '+(ID:1 ID:2 ID:3)';
$this->assertEquals($expected, $writer->generateQueryString($criteria));
}
public function testBasicNotInQueryString()
{
$criteria = new SearchCriterion('ID', [1,2,3], SearchCriterion::NOT_IN);
$writer = SolrSearchQueryWriterIn::create();
$expected = '-(ID:1 ID:2 ID:3)';
$this->assertEquals($expected, $writer->generateQueryString($criteria));
}
public function testBasicGreaterEqualQueryString()
{
$criteria = new SearchCriterion('Stock', 2, SearchCriterion::GREATER_EQUAL);
$writer = SolrSearchQueryWriterRange::create();
$expected = '+(Stock:[2 TO *])';
$this->assertEquals($expected, $writer->generateQueryString($criteria));
}
public function testBasicGreaterQueryString()
{
$criteria = new SearchCriterion('Stock', 2, SearchCriterion::GREATER_THAN);
$writer = SolrSearchQueryWriterRange::create();
$expected = '+(Stock:{2 TO *})';
$this->assertEquals($expected, $writer->generateQueryString($criteria));
}
public function testBasicLessEqualQueryString()
{
$criteria = new SearchCriterion('Stock', 2, SearchCriterion::LESS_EQUAL);
$writer = SolrSearchQueryWriterRange::create();
$expected = '+(Stock:[* TO 2])';
$this->assertEquals($expected, $writer->generateQueryString($criteria));
}
public function testBasicLessQueryString()
{
$criteria = new SearchCriterion('Stock', 2, SearchCriterion::LESS_THAN);
$writer = SolrSearchQueryWriterRange::create();
$expected = '+(Stock:{* TO 2})';
$this->assertEquals($expected, $writer->generateQueryString($criteria));
}
public function testBasicIsNullQueryString()
{
$criteria = new SearchCriterion('Stock', null, SearchCriterion::ISNULL);
$writer = SolrSearchQueryWriterRange::create();
$expected = '-(Stock:[* TO *])';
$this->assertEquals($expected, $writer->generateQueryString($criteria));
}
public function testBasicIsNotNullQueryString()
{
$criteria = new SearchCriterion('Stock', null, SearchCriterion::ISNOTNULL);
$writer = SolrSearchQueryWriterRange::create();
$expected = '+(Stock:[* TO *])';
$this->assertEquals($expected, $writer->generateQueryString($criteria));
}
public function testConjunction()
{
$adapter = new SolrSearchAdapter();
$this->assertEquals(' AND ', $adapter->getConjunctionFor(SearchCriteria::CONJUNCTION_AND));
$this->assertEquals(' OR ', $adapter->getConjunctionFor(SearchCriteria::CONJUNCTION_OR));
}
/**
* @expectedException InvalidArgumentException
*/
public function testConjunctionFailure()
{
$adapter = new SolrSearchAdapter();
$adapter->getConjunctionFor('FAIL');
}
/**
* @throws \Exception
*/
public function testComplexPositiveFilterQueryString()
{
$expected = '+((+(Page_TaxonomyTerms_ID:"Lego") AND +(Page_TaxonomyTerms_ID:"StarWars") AND +(Stock:[5 TO *]))';
$expected .= ' OR (+(Page_TaxonomyTerms_ID:"Books") AND +(Page_TaxonomyTerms_ID:"HarryPotter")';
$expected .= ' AND +(Stock:[1 TO *])))';
$legoCriteria = SearchCriteria::create(
'Page_TaxonomyTerms_ID',
[
'Lego',
],
SearchCriterion::IN
);
$legoCriteria->addAnd(
'Page_TaxonomyTerms_ID',
[
'StarWars',
],
SearchCriterion::IN
);
$legoCriteria->addAnd(
'Stock',
5,
SearchCriterion::GREATER_EQUAL
);
$booksCriteria = SearchCriteria::create(
'Page_TaxonomyTerms_ID',
[
'Books',
],
SearchCriterion::IN
);
$booksCriteria->addAnd(
'Page_TaxonomyTerms_ID',
[
'HarryPotter',
],
SearchCriterion::IN
);
$booksCriteria->addAnd(
'Stock',
1,
SearchCriterion::GREATER_EQUAL
);
// Combine the two criteria with an `OR` conjunction
$criteria = SearchCriteria::create($legoCriteria)->addOr($booksCriteria);
$query = SearchQuery::create();
$query->filterBy($criteria);
$index = new SolrIndexTest_FakeIndex();
$this->assertTrue(in_array($expected, $index->getFiltersComponent($query)));
}
/**
* @throws \Exception
*/
public function testComplexNegativeFilterQueryString()
{
$expected = '+((-(Page_TaxonomyTerms_ID:"Lego" Page_TaxonomyTerms_ID:"StarWars") AND +(Stock:[* TO 5]))';
$expected .= ' OR (-(Page_TaxonomyTerms_ID:"Books" Page_TaxonomyTerms_ID:"HarryPotter")';
$expected .= ' AND +(Stock:[* TO 2])))';
$legoCriteria = SearchCriteria::create(
'Page_TaxonomyTerms_ID',
[
'Lego',
'StarWars',
],
SearchCriterion::NOT_IN
);
$legoCriteria->addAnd(
'Stock',
5,
SearchCriterion::LESS_EQUAL
);
$booksCriteria = SearchCriteria::create(
'Page_TaxonomyTerms_ID',
[
'Books',
'HarryPotter',
],
SearchCriterion::NOT_IN
);
$booksCriteria->addAnd(
'Stock',
2,
SearchCriterion::LESS_EQUAL
);
// Combine the two criteria with an `OR` conjunction
$criteria = SearchCriteria::create($legoCriteria)->addOr($booksCriteria);
$query = SearchQuery::create();
$query->filterBy($criteria);
$index = new SolrIndexTest_FakeIndex();
$this->assertTrue(in_array($expected, $index->getFiltersComponent($query)));
}
}