From 43f415555a8ad61a9741a1db858b1dd47cd19f2d Mon Sep 17 00:00:00 2001 From: cpenny Date: Thu, 12 Jul 2018 13:28:40 +1200 Subject: [PATCH] Added complex filtering via Criteria/Criterion and Adapters/Writers for Solr. --- _config/config.yml | 5 + docs/en/00_index.md | 1 + docs/en/04_querying.md | 161 +++++++++++ .../Adapters/SearchAdapterInterface.php | 56 ++++ src/Search/Adapters/SolrSearchAdapter.php | 115 ++++++++ src/Search/Criteria/SearchCriteria.php | 239 ++++++++++++++++ .../Criteria/SearchCriteriaInterface.php | 36 +++ src/Search/Criteria/SearchCriterion.php | 269 ++++++++++++++++++ .../Queries/AbstractSearchQueryWriter.php | 21 ++ src/Search/Queries/SearchQuery.php | 99 +++++++ src/Solr/SolrIndex.php | 48 +++- .../Writers/SolrSearchQueryWriterBasic.php | 54 ++++ src/Solr/Writers/SolrSearchQueryWriterIn.php | 86 ++++++ .../Writers/SolrSearchQueryWriterRange.php | 150 ++++++++++ tests/SolrWritersTest.php | 242 ++++++++++++++++ 15 files changed, 1581 insertions(+), 1 deletion(-) create mode 100644 src/Search/Adapters/SearchAdapterInterface.php create mode 100644 src/Search/Adapters/SolrSearchAdapter.php create mode 100644 src/Search/Criteria/SearchCriteria.php create mode 100644 src/Search/Criteria/SearchCriteriaInterface.php create mode 100644 src/Search/Criteria/SearchCriterion.php create mode 100644 src/Search/Queries/AbstractSearchQueryWriter.php create mode 100644 src/Solr/Writers/SolrSearchQueryWriterBasic.php create mode 100644 src/Solr/Writers/SolrSearchQueryWriterIn.php create mode 100644 src/Solr/Writers/SolrSearchQueryWriterRange.php create mode 100644 tests/SolrWritersTest.php diff --git a/_config/config.yml b/_config/config.yml index 50ea2b3..f06b741 100644 --- a/_config/config.yml +++ b/_config/config.yml @@ -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 ]] diff --git a/docs/en/00_index.md b/docs/en/00_index.md index d71e99e..4f75201 100644 --- a/docs/en/00_index.md +++ b/docs/en/00_index.md @@ -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) diff --git a/docs/en/04_querying.md b/docs/en/04_querying.md index 4c7b818..71c7ee3 100644 --- a/docs/en/04_querying.md +++ b/docs/en/04_querying.md @@ -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. \ No newline at end of file diff --git a/src/Search/Adapters/SearchAdapterInterface.php b/src/Search/Adapters/SearchAdapterInterface.php new file mode 100644 index 0000000..8050e27 --- /dev/null +++ b/src/Search/Adapters/SearchAdapterInterface.php @@ -0,0 +1,56 @@ +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'); + } + } +} diff --git a/src/Search/Criteria/SearchCriteria.php b/src/Search/Criteria/SearchCriteria.php new file mode 100644 index 0000000..5c4f64d --- /dev/null +++ b/src/Search/Criteria/SearchCriteria.php @@ -0,0 +1,239 @@ +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; + } +} diff --git a/src/Search/Criteria/SearchCriteriaInterface.php b/src/Search/Criteria/SearchCriteriaInterface.php new file mode 100644 index 0000000..411d2a2 --- /dev/null +++ b/src/Search/Criteria/SearchCriteriaInterface.php @@ -0,0 +1,36 @@ +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; + } +} diff --git a/src/Search/Queries/AbstractSearchQueryWriter.php b/src/Search/Queries/AbstractSearchQueryWriter.php new file mode 100644 index 0000000..1496a39 --- /dev/null +++ b/src/Search/Queries/AbstractSearchQueryWriter.php @@ -0,0 +1,21 @@ +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; + } } diff --git a/src/Solr/SolrIndex.php b/src/Solr/SolrIndex.php index d3e25e7..ad175a2 100644 --- a/src/Solr/SolrIndex.php +++ b/src/Solr/SolrIndex.php @@ -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; diff --git a/src/Solr/Writers/SolrSearchQueryWriterBasic.php b/src/Solr/Writers/SolrSearchQueryWriterBasic.php new file mode 100644 index 0000000..46550d1 --- /dev/null +++ b/src/Solr/Writers/SolrSearchQueryWriterBasic.php @@ -0,0 +1,54 @@ +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 ':'; + } +} diff --git a/src/Solr/Writers/SolrSearchQueryWriterIn.php b/src/Solr/Writers/SolrSearchQueryWriterIn.php new file mode 100644 index 0000000..37f62f7 --- /dev/null +++ b/src/Solr/Writers/SolrSearchQueryWriterIn.php @@ -0,0 +1,86 @@ +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 ':'; + } +} diff --git a/src/Solr/Writers/SolrSearchQueryWriterRange.php b/src/Solr/Writers/SolrSearchQueryWriterRange.php new file mode 100644 index 0000000..098a14e --- /dev/null +++ b/src/Solr/Writers/SolrSearchQueryWriterRange.php @@ -0,0 +1,150 @@ +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'); + } + } +} diff --git a/tests/SolrWritersTest.php b/tests/SolrWritersTest.php new file mode 100644 index 0000000..0354f05 --- /dev/null +++ b/tests/SolrWritersTest.php @@ -0,0 +1,242 @@ +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))); + } +}