From 9208e1450df9607e4e37b888e56aaf399d51d1b8 Mon Sep 17 00:00:00 2001 From: Holger Veltrup Date: Fri, 3 May 2024 14:32:39 +0200 Subject: [PATCH] feat: date filter and facets --- src/Dto/Search/Query/DateRangeRound.php | 24 +++ .../Query/Facet/AbsoluteDateRangeFacet.php | 27 +++ src/Dto/Search/Query/Facet/CategoryFacet.php | 4 +- .../Query/Facet/ContentSectionTypeFacet.php | 4 +- src/Dto/Search/Query/Facet/Facet.php | 5 +- src/Dto/Search/Query/Facet/FacetField.php | 4 +- .../Search/Query/Facet/FacetMultiQuery.php | 4 +- src/Dto/Search/Query/Facet/FacetQuery.php | 5 +- src/Dto/Search/Query/Facet/GroupFacet.php | 4 +- .../Search/Query/Facet/ObjectTypeFacet.php | 4 +- .../Query/Facet/RelativeDateRangeFacet.php | 30 +++ src/Dto/Search/Query/Facet/SiteFacet.php | 4 +- .../Query/Filter/AbsoluteDateRangeFilter.php | 6 + .../Query/Filter/RelativeDateRangeFilter.php | 74 +++++++- src/Dto/Search/Query/SearchQuery.php | 4 +- src/Dto/Search/Query/SearchQueryBuilder.php | 13 +- .../Indexer/InternalResourceIndexer.php | 3 +- src/Service/ResourceChannelBasedIndexName.php | 4 + .../Search/SolrAbsoluteDateRangeFacet.php | 45 +++++ src/Service/Search/SolrDateMapper.php | 95 ++++++++++ src/Service/Search/SolrQueryFacetAppender.php | 173 ++++++++++++++++++ src/Service/Search/SolrRangeFacet.php | 13 ++ .../Search/SolrRelativeDateRangeFacet.php | 84 +++++++++ src/Service/Search/SolrSearch.php | 109 ++++------- 24 files changed, 646 insertions(+), 96 deletions(-) create mode 100644 src/Dto/Search/Query/DateRangeRound.php create mode 100644 src/Dto/Search/Query/Facet/AbsoluteDateRangeFacet.php create mode 100644 src/Dto/Search/Query/Facet/RelativeDateRangeFacet.php create mode 100644 src/Service/Search/SolrAbsoluteDateRangeFacet.php create mode 100644 src/Service/Search/SolrDateMapper.php create mode 100644 src/Service/Search/SolrQueryFacetAppender.php create mode 100644 src/Service/Search/SolrRangeFacet.php create mode 100644 src/Service/Search/SolrRelativeDateRangeFacet.php diff --git a/src/Dto/Search/Query/DateRangeRound.php b/src/Dto/Search/Query/DateRangeRound.php new file mode 100644 index 0000000..1cff5b9 --- /dev/null +++ b/src/Dto/Search/Query/DateRangeRound.php @@ -0,0 +1,24 @@ +from === null && $this->to === null) { + throw new InvalidArgumentException( + 'At least `from` or `to` must be specified' + ); + } } public function getQuery(): string diff --git a/src/Dto/Search/Query/Filter/RelativeDateRangeFilter.php b/src/Dto/Search/Query/Filter/RelativeDateRangeFilter.php index a919819..ce0f576 100644 --- a/src/Dto/Search/Query/Filter/RelativeDateRangeFilter.php +++ b/src/Dto/Search/Query/Filter/RelativeDateRangeFilter.php @@ -4,15 +4,19 @@ namespace Atoolo\Search\Dto\Search\Query\Filter; +use Atoolo\Search\Dto\Search\Query\DateRangeRound; use DateInterval; +use DateTime; use InvalidArgumentException; class RelativeDateRangeFilter extends Filter { public function __construct( - private readonly ?\DateTime $base, + private readonly ?DateTime $base, private readonly ?DateInterval $before, private readonly ?DateInterval $after, + private readonly ?DateRangeRound $roundStart, + private readonly ?DateRangeRound $roundEnd, ?string $key = null ) { parent::__construct( @@ -29,22 +33,38 @@ public function getQuery(): string private function toSolrDateRage(): string { if ($this->before === null) { - $from = $this->getBaseInSolrSyntax() . "/DAY"; + $from = $this->roundStart($this->getBaseInSolrSyntax()); } else { - $from = $this->toSolrIntervalSyntax($this->before, '-') . - '/DAY'; + $from = $this->roundStart( + $this->toSolrIntervalSyntax($this->before, '-') + ); } if ($this->after === null) { - $to = $this->getBaseInSolrSyntax() . "/DAY+1DAY-1SECOND"; + $to = $this->roundEnd($this->getBaseInSolrSyntax()); } else { - $to = $this->toSolrIntervalSyntax($this->after, '+') . - "/DAY+1DAY-1SECOND"; + $to = $this->roundEnd( + $this->toSolrIntervalSyntax($this->after, '+') + ); } return '[' . $from . ' TO ' . $to . ']'; } + private function roundStart(string $start): string + { + return $start . $this->toSolrRound( + $this->roundStart ?? DateRangeRound::START_OF_DAY + ); + } + + private function roundEnd(string $start): string + { + return $start . $this->toSolrRound( + $this->roundEnd ?? DateRangeRound::END_OF_DAY + ); + } + private function getBaseInSolrSyntax(): string { if ($this->base === null) { @@ -88,4 +108,44 @@ private function toSolrIntervalSyntax( return $interval; } + + private function toSolrRound(DateRangeRound $round): string + { + if ($round === DateRangeRound::START_OF_DAY) { + return '/DAY'; + } + if ($round === DateRangeRound::START_OF_PREVIOUS_DAY) { + return '/DAY-1DAY'; + } + if ($round === DateRangeRound::END_OF_DAY) { + return '/DAY+1DAY-1SECOND'; + } + if ($round === DateRangeRound::END_OF_PREVIOUS_DAY) { + return '/DAY-1SECOND'; + } + if ($round === DateRangeRound::START_OF_MONTH) { + return '/MONTH'; + } + if ($round === DateRangeRound::START_OF_PREVIOUS_MONTH) { + return '/MONTH-1MONTH'; + } + if ($round === DateRangeRound::END_OF_MONTH) { + return '/MONTH+1MONTH-1SECOND'; + } + if ($round === DateRangeRound::END_OF_PREVIOUS_MONTH) { + return '/MONTH-1SECOND'; + } + if ($round === DateRangeRound::START_OF_YEAR) { + return '/YEAR'; + } + if ($round === DateRangeRound::START_OF_PREVIOUS_YEAR) { + return '/YEAR-1YEAR'; + } + if ($round === DateRangeRound::END_OF_YEAR) { + return '/YEAR+1YEAR-1SECOND'; + } + if ($round === DateRangeRound::END_OF_PREVIOUS_YEAR) { + return '/YEAR-1SECOND'; + } + } } diff --git a/src/Dto/Search/Query/SearchQuery.php b/src/Dto/Search/Query/SearchQuery.php index 7ba985c..98f4c5a 100644 --- a/src/Dto/Search/Query/SearchQuery.php +++ b/src/Dto/Search/Query/SearchQuery.php @@ -7,6 +7,7 @@ use Atoolo\Search\Dto\Search\Query\Facet\Facet; use Atoolo\Search\Dto\Search\Query\Filter\Filter; use Atoolo\Search\Dto\Search\Query\Sort\Criteria; +use DateTimeZone; /** * @codeCoverageIgnore @@ -28,7 +29,8 @@ public function __construct( public readonly array $sort, public readonly array $filter, public readonly array $facets, - public readonly QueryOperator $defaultQueryOperator + public readonly QueryOperator $defaultQueryOperator, + public readonly ?DateTimeZone $timeZone ) { } } diff --git a/src/Dto/Search/Query/SearchQueryBuilder.php b/src/Dto/Search/Query/SearchQueryBuilder.php index 0f63f7c..0063a58 100644 --- a/src/Dto/Search/Query/SearchQueryBuilder.php +++ b/src/Dto/Search/Query/SearchQueryBuilder.php @@ -7,6 +7,7 @@ use Atoolo\Search\Dto\Search\Query\Facet\Facet; use Atoolo\Search\Dto\Search\Query\Filter\Filter; use Atoolo\Search\Dto\Search\Query\Sort\Criteria; +use DateTimeZone; class SearchQueryBuilder { @@ -31,6 +32,8 @@ class SearchQueryBuilder private QueryOperator $defaultQueryOperator = QueryOperator::OR; + private ?DateTimeZone $timeZone = null; + public function __construct() { } @@ -136,6 +139,13 @@ public function defaultQueryOperator( return $this; } + public function timeZone( + DateTimeZone $timeZone + ): static { + $this->timeZone = $timeZone; + return $this; + } + public function build(): SearchQuery { return new SearchQuery( @@ -146,7 +156,8 @@ public function build(): SearchQuery sort: $this->sort, filter: $this->filter, facets: array_values($this->facets), - defaultQueryOperator: $this->defaultQueryOperator + defaultQueryOperator: $this->defaultQueryOperator, + timeZone: $this->timeZone ); } } diff --git a/src/Service/Indexer/InternalResourceIndexer.php b/src/Service/Indexer/InternalResourceIndexer.php index c633042..4797d32 100644 --- a/src/Service/Indexer/InternalResourceIndexer.php +++ b/src/Service/Indexer/InternalResourceIndexer.php @@ -190,7 +190,8 @@ private function loadIndexerParameter(): IndexerParameter return new IndexerParameter( $config->name, $config->data->getInt( - 'cleanupThreshold' + 'cleanupThreshold', + 1000 ), $config->data->getInt( 'chunkSize', diff --git a/src/Service/ResourceChannelBasedIndexName.php b/src/Service/ResourceChannelBasedIndexName.php index dabf43a..fa2bf08 100644 --- a/src/Service/ResourceChannelBasedIndexName.php +++ b/src/Service/ResourceChannelBasedIndexName.php @@ -20,6 +20,10 @@ public function __construct( */ public function name(ResourceLanguage $lang): string { + if ($lang === ResourceLanguage::default()) { + return $this->resourceChannel->searchIndex; + } + $locale = $this->langToAvailableLocale($this->resourceChannel, $lang); if (empty($locale)) { diff --git a/src/Service/Search/SolrAbsoluteDateRangeFacet.php b/src/Service/Search/SolrAbsoluteDateRangeFacet.php new file mode 100644 index 0000000..b3be28c --- /dev/null +++ b/src/Service/Search/SolrAbsoluteDateRangeFacet.php @@ -0,0 +1,45 @@ +from); + } + + public function getEnd(): string + { + return SolrDateMapper::mapDateTime($this->to); + } + + public function getGap(): ?string + { + if ($this->gap === null) { + return null; + } + return SolrDateMapper::mapDateInterval($this->gap, '+'); + } +} diff --git a/src/Service/Search/SolrDateMapper.php b/src/Service/Search/SolrDateMapper.php new file mode 100644 index 0000000..82ca289 --- /dev/null +++ b/src/Service/Search/SolrDateMapper.php @@ -0,0 +1,95 @@ +y > 0) { + $interval = $operator . $value->y . 'YEARS'; + } + if ($value->m > 0) { + $interval = $operator . $value->m . 'MONTHS'; + } + if ($value->d > 0) { + $interval = $operator . $value->d . 'DAYS'; + } + if ($value->h > 0) { + throw new InvalidArgumentException( + 'Hours are not supported for the RelativeDateRangeFilter' + ); + } + if ($value->i > 0) { + throw new InvalidArgumentException( + 'Minutes are not supported for the RelativeDateRangeFilter' + ); + } + if ($value->s > 0) { + throw new InvalidArgumentException( + 'Seconds are not supported for the RelativeDateRangeFilter' + ); + } + return $interval; + } + + public static function mapDateTime(DateTime $date): string + { + $formatter = clone $date; + $formatter->setTimezone(new DateTimeZone('UTC')); + return $formatter->format('Y-m-d\TH:i:s\Z'); + } + + public static function mapDateRangeRound(DateRangeRound $round): string + { + if ($round === DateRangeRound::START_OF_DAY) { + return '/DAY'; + } + if ($round === DateRangeRound::START_OF_PREVIOUS_DAY) { + return '/DAY-1DAY'; + } + if ($round === DateRangeRound::END_OF_DAY) { + return '/DAY+1DAY-1SECOND'; + } + if ($round === DateRangeRound::END_OF_PREVIOUS_DAY) { + return '/DAY-1SECOND'; + } + if ($round === DateRangeRound::START_OF_MONTH) { + return '/MONTH'; + } + if ($round === DateRangeRound::START_OF_PREVIOUS_MONTH) { + return '/MONTH-1MONTH'; + } + if ($round === DateRangeRound::END_OF_MONTH) { + return '/MONTH+1MONTH-1SECOND'; + } + if ($round === DateRangeRound::END_OF_PREVIOUS_MONTH) { + return '/MONTH-1SECOND'; + } + if ($round === DateRangeRound::START_OF_YEAR) { + return '/YEAR'; + } + if ($round === DateRangeRound::START_OF_PREVIOUS_YEAR) { + return '/YEAR-1YEAR'; + } + if ($round === DateRangeRound::END_OF_YEAR) { + return '/YEAR+1YEAR-1SECOND'; + } + if ($round === DateRangeRound::END_OF_PREVIOUS_YEAR) { + return '/YEAR-1SECOND'; + } + } +} diff --git a/src/Service/Search/SolrQueryFacetAppender.php b/src/Service/Search/SolrQueryFacetAppender.php new file mode 100644 index 0000000..e37c26d --- /dev/null +++ b/src/Service/Search/SolrQueryFacetAppender.php @@ -0,0 +1,173 @@ +mapFacet($facet); + + if ($facet instanceof FacetField) { + $this->addFacetFieldToSolrQuery($facet); + } elseif ($facet instanceof FacetQuery) { + $this->addFacetQueryToSolrQuery($facet); + } elseif ($facet instanceof FacetMultiQuery) { + $this->addFacetMultiQueryToSolrQuery($facet); + } elseif ($facet instanceof SolrRangeFacet) { + $this->addFacetRangeToSolrQuery($facet); + } else { + throw new InvalidArgumentException( + 'Unsupported facet-class ' . get_class($facet) + ); + } + } + + private function mapFacet(Facet $facet): Facet + { + switch (true) { + case $facet instanceof AbsoluteDateRangeFacet: + return new SolrAbsoluteDateRangeFacet( + $facet->key, + $facet->from, + $facet->to, + $facet->gap, + $facet->excludeFilter + ); + case $facet instanceof RelativeDateRangeFacet: + return new SolrRelativeDateRangeFacet( + $facet->key, + $facet->base, + $facet->before, + $facet->after, + $facet->gap, + $facet->roundStart, + $facet->roundEnd, + $facet->excludeFilter + ); + default: + return $facet; + } + } + + /** + * https://solarium.readthedocs.io/en/stable/queries/select-query/building-a-select-query/components/facetset-component/facet-field/ + */ + private function addFacetFieldToSolrQuery( + FacetField $facet + ): void { + $facetSet = $this->solrQuery->getFacetSet(); + /** @var Field $solrFacet */ + $solrFacet = $facetSet->createFacetField($facet->key); + $solrFacet->setMinCount(1); + $solrFacet->setExcludes($facet->excludeFilter); + $solrFacet->setField($this->getFacetField($facet)); + $solrFacet->setTerms($facet->terms); + } + + private function getFacetField(Facet $facet): string + { + switch (true) { + case $facet instanceof CategoryFacet: + return 'sp_category_path'; + case $facet instanceof ContentSectionTypeFacet: + return 'sp_contenttype'; + case $facet instanceof GroupFacet: + return 'sp_group_path'; + case $facet instanceof ObjectTypeFacet: + return 'sp_objecttype'; + case $facet instanceof SiteFacet: + return 'sp_site'; + case $facet instanceof SolrAbsoluteDateRangeFacet: + return 'sp_date_list'; + case $facet instanceof SolrRelativeDateRangeFacet: + return 'sp_date_list'; + default: + throw new InvalidArgumentException( + 'Unsupported facet-field-class ' . get_class($facet) + ); + } + } + + /** + * https://solarium.readthedocs.io/en/stable/queries/select-query/building-a-select-query/components/facetset-component/facet-query/ + */ + private function addFacetQueryToSolrQuery( + FacetQuery $facet + ): void { + $facetSet = $this->solrQuery->getFacetSet(); + $facetSet->createFacetQuery($facet->key) + ->setQuery($facet->query) + ->setExcludes($facet->excludeFilter); + } + + /** + * https://solarium.readthedocs.io/en/stable/queries/select-query/building-a-select-query/components/facetset-component/facet-multiquery/ + */ + private function addFacetMultiQueryToSolrQuery( + FacetMultiQuery $facet + ): void { + $facetSet = $this->solrQuery->getFacetSet(); + $solrFacet = $facetSet->createFacetMultiQuery($facet->key); + $solrFacet->setExcludes($facet->excludeFilter); + foreach ($facet->queries as $facetQuery) { + $solrFacet->createQuery( + $facetQuery->key, + $facetQuery->query + ); + } + } + + private function addFacetRangeToSolrQuery( + SolrRangeFacet $facet + ): void { + + if ($facet->getGap() === null) { + // without `gap` it is a simple facet query + $facetQuery = new FacetQuery( + $facet->key, + $this->getFacetField($facet) . ':' . + '[' . + $facet->getStart() . + ' TO ' . + $facet->getEnd() . + ']', + $facet->excludeFilter + ); + $this->addFacetQueryToSolrQuery($facetQuery); + return; + } + + $facetSet = $this->solrQuery->getFacetSet(); + $solrFacet = $facetSet->createFacetRange($facet->key); + $solrFacet->setMinCount(1); + $solrFacet->setExcludes($facet->excludeFilter); + $solrFacet + ->setField($this->getFacetField($facet)) + ->setStart($facet->getStart()) + ->setEnd($facet->getEnd()) + ->setGap($facet->getGap()); + } +} diff --git a/src/Service/Search/SolrRangeFacet.php b/src/Service/Search/SolrRangeFacet.php new file mode 100644 index 0000000..5e6d580 --- /dev/null +++ b/src/Service/Search/SolrRangeFacet.php @@ -0,0 +1,13 @@ +before === null) { + return $this->roundStart($this->getBaseInSolrSyntax()); + } + + return $this->roundStart( + $this->getBaseInSolrSyntax() . + SolrDateMapper::mapDateInterval($this->before, '-') + ); + } + + public function getEnd(): string + { + if ($this->after === null) { + return $this->roundEnd($this->getBaseInSolrSyntax()); + } + return $this->roundEnd( + $this->getBaseInSolrSyntax() . + SolrDateMapper::mapDateInterval($this->after, '+') + ); + } + + private function roundStart(string $start): string + { + return $start . SolrDateMapper::mapDateRangeRound( + $this->roundStart ?? DateRangeRound::START_OF_DAY + ); + } + + private function roundEnd(string $start): string + { + return $start . SolrDateMapper::mapDateRangeRound( + $this->roundEnd ?? DateRangeRound::END_OF_DAY + ); + } + + public function getGap(): ?string + { + if ($this->gap === null) { + return null; + } + return SolrDateMapper::mapDateInterval($this->gap, '+'); + } + + private function getBaseInSolrSyntax(): string + { + if ($this->base === null) { + return 'NOW'; + } + + return SolrDateMapper::mapDateTime($this->base); + } +} diff --git a/src/Service/Search/SolrSearch.php b/src/Service/Search/SolrSearch.php index 07a06e8..88fa192 100644 --- a/src/Service/Search/SolrSearch.php +++ b/src/Service/Search/SolrSearch.php @@ -5,9 +5,6 @@ namespace Atoolo\Search\Service\Search; use Atoolo\Resource\ResourceLanguage; -use Atoolo\Search\Dto\Search\Query\Facet\FacetField; -use Atoolo\Search\Dto\Search\Query\Facet\FacetMultiQuery; -use Atoolo\Search\Dto\Search\Query\Facet\FacetQuery; use Atoolo\Search\Dto\Search\Query\Filter\Filter; use Atoolo\Search\Dto\Search\Query\QueryOperator; use Atoolo\Search\Dto\Search\Query\SearchQuery; @@ -24,7 +21,6 @@ use Atoolo\Search\Service\IndexName; use Atoolo\Search\Service\SolrClientFactory; use InvalidArgumentException; -use Solarium\Component\Facet\Field; use Solarium\Core\Client\Client; use Solarium\QueryType\Select\Query\Query as SolrSelectQuery; use Solarium\QueryType\Select\Result\Result as SelectResult; @@ -91,6 +87,12 @@ private function buildSolrQuery( $query->facets ); + if ($query->timeZone !== null) { + $solrQuery->setTimezone($query->timeZone); + } elseif (date_default_timezone_get()) { + $solrQuery->setTimezone(date_default_timezone_get()); + } + return $solrQuery; } @@ -182,67 +184,9 @@ private function addFacetListToSolrQuery( SolrSelectQuery $solrQuery, array $facetList ): void { + $facetAppender = new SolrQueryFacetAppender($solrQuery); foreach ($facetList as $facet) { - if ($facet instanceof FacetField) { - $this->addFacetFieldToSolrQuery($solrQuery, $facet); - } elseif ($facet instanceof FacetQuery) { - $this->addFacetQueryToSolrQuery($solrQuery, $facet); - } elseif ($facet instanceof FacetMultiQuery) { - $this->addFacetMultiQueryToSolrQuery($solrQuery, $facet); - } else { - throw new InvalidArgumentException( - 'Unsupported facet-class ' . get_class($facet) - ); - } - } - } - - /** - * https://solarium.readthedocs.io/en/stable/queries/select-query/building-a-select-query/components/facetset-component/facet-field/ - */ - private function addFacetFieldToSolrQuery( - SolrSelectQuery $solrQuery, - FacetField $facet - ): void { - $facetSet = $solrQuery->getFacetSet(); - $field = $facet->field; - // https://solr.apache.org/guide/solr/latest/query-guide/faceting.html#tagging-and-excluding-filters - if ($facet->excludeFilter !== null) { - $field = '{!ex=' . $facet->excludeFilter . '}' . $field; - } - /** @var Field $solariumFacet */ - $solariumFacet = $facetSet->createFacetField($facet->key); - $solariumFacet - ->setField($field) - ->setTerms($facet->terms); - } - - /** - * https://solarium.readthedocs.io/en/stable/queries/select-query/building-a-select-query/components/facetset-component/facet-query/ - */ - private function addFacetQueryToSolrQuery( - SolrSelectQuery $solrQuery, - FacetQuery $facet - ): void { - $facetSet = $solrQuery->getFacetSet(); - $facetSet->createFacetQuery($facet->key) - ->setQuery($facet->query); - } - - /** - * https://solarium.readthedocs.io/en/stable/queries/select-query/building-a-select-query/components/facetset-component/facet-multiquery/ - */ - private function addFacetMultiQueryToSolrQuery( - SolrSelectQuery $solrQuery, - FacetMultiQuery $facet - ): void { - $facetSet = $solrQuery->getFacetSet(); - $solrFacet = $facetSet->createFacetMultiQuery($facet->key); - foreach ($facet->queries as $facetQuery) { - $solrFacet->createQuery( - $facetQuery->key, - $facetQuery->query - ); + $facetAppender->append($facet); } } @@ -281,20 +225,32 @@ private function buildFacetGroupList( $facetGroupList = []; foreach ($query->facets as $facet) { - /** @var ?\Solarium\Component\Result\Facet\Field $resultFacet */ $resultFacet = $facetSet->getFacet($facet->key); if ($resultFacet === null) { continue; } - $facetGroupList[] = $this->buildFacetGroup( - $facet->key, - $resultFacet - ); + if ( + $resultFacet instanceof \Solarium\Component\Result\Facet\Field + ) { + $facetGroupList[] = $this->buildFacetGroupByField( + $facet->key, + $resultFacet + ); + } + + if ( + $resultFacet instanceof \Solarium\Component\Result\Facet\Query + ) { + $facetGroupList[] = $this->buildFacetGroupByQuery( + $facet->key, + $resultFacet + ); + } } return $facetGroupList; } - private function buildFacetGroup( + private function buildFacetGroupByField( string $key, \Solarium\Component\Result\Facet\Field $solrFacet ): FacetGroup { @@ -309,4 +265,17 @@ private function buildFacetGroup( } return new FacetGroup($key, $facetList); } + + private function buildFacetGroupByQuery( + string $key, + \Solarium\Component\Result\Facet\Query $solrFacet + ): FacetGroup { + $facetList = []; + + $value = $solrFacet->getValue(); + $value = is_int($value) ? $value : 0; + + $facetList[] = new Facet($key, $value); + return new FacetGroup($key, $facetList); + } }