From 9bf3cee4070b464458930e6450e60b67b1852312 Mon Sep 17 00:00:00 2001 From: mlnkng Date: Tue, 4 Jun 2024 07:04:54 -0700 Subject: [PATCH] Rename and add more query classes. --- .../lib/arSolrAggregationFilter.class.php | 41 ++ .../lib/arSolrAggregationTerms.class.php | 36 + .../lib/arSolrBoolQuery.class.php | 12 +- ....class.php => arSolrExistsQuery.class.php} | 4 +- ...lass.php => arSolrMatchAllQuery.class.php} | 4 +- .../lib/arSolrNestedQuery.class.php | 116 +++ .../arSolrPlugin/lib/arSolrPlugin.class.php | 2 +- .../lib/arSolrPluginQuery.class.php | 669 ++++++++++++++++++ .../lib/arSolrPluginUtil.class.php | 3 +- .../arSolrPlugin/lib/arSolrQuery.class.php | 65 +- .../lib/arSolrQueryClass.class.php | 70 ++ .../arSolrPlugin/lib/arSolrQueryIds.class.php | 74 ++ .../lib/arSolrQueryString.class.php | 232 ++++++ ...e.class.php => arSolrRangeQuery.class.php} | 4 +- ...rm.class.php => arSolrTermQuery.class.php} | 4 +- .../lib/QubitSolrAclSearch.class.php | 182 +++++ 16 files changed, 1495 insertions(+), 23 deletions(-) create mode 100644 plugins/arSolrPlugin/lib/arSolrAggregationFilter.class.php create mode 100644 plugins/arSolrPlugin/lib/arSolrAggregationTerms.class.php rename plugins/arSolrPlugin/lib/{arSolrExists.class.php => arSolrExistsQuery.class.php} (94%) rename plugins/arSolrPlugin/lib/{arSolrMatchAll.class.php => arSolrMatchAllQuery.class.php} (93%) create mode 100644 plugins/arSolrPlugin/lib/arSolrNestedQuery.class.php create mode 100644 plugins/arSolrPlugin/lib/arSolrPluginQuery.class.php create mode 100644 plugins/arSolrPlugin/lib/arSolrQueryClass.class.php create mode 100644 plugins/arSolrPlugin/lib/arSolrQueryIds.class.php create mode 100644 plugins/arSolrPlugin/lib/arSolrQueryString.class.php rename plugins/arSolrPlugin/lib/{arSolrRange.class.php => arSolrRangeQuery.class.php} (95%) rename plugins/arSolrPlugin/lib/{arSolrTerm.class.php => arSolrTermQuery.class.php} (96%) create mode 100644 plugins/qbAclPlugin/lib/QubitSolrAclSearch.class.php diff --git a/plugins/arSolrPlugin/lib/arSolrAggregationFilter.class.php b/plugins/arSolrPlugin/lib/arSolrAggregationFilter.class.php new file mode 100644 index 0000000000..40ca4b4cc3 --- /dev/null +++ b/plugins/arSolrPlugin/lib/arSolrAggregationFilter.class.php @@ -0,0 +1,41 @@ +. + */ + +/** + * arSolrAggregationFilter. + */ +class arSolrAggregationFilter extends arSolrQuery +{ + public function __construct(string $name, $filter = null) + { + if (null !== $filter) { + $this->setFilter($filter); + } + } + + /** + * Set the filter for this aggregation. + * + * @return $this + */ + public function setFilter(arSolrTermQuery $filter): self + { + return $this->setParam('filter', $filter); + } +} diff --git a/plugins/arSolrPlugin/lib/arSolrAggregationTerms.class.php b/plugins/arSolrPlugin/lib/arSolrAggregationTerms.class.php new file mode 100644 index 0000000000..a8bade29b8 --- /dev/null +++ b/plugins/arSolrPlugin/lib/arSolrAggregationTerms.class.php @@ -0,0 +1,36 @@ +. + */ + +/** + * arSolrAggregationTerms. + */ +class arSolrAggregationTerms extends arSolrQuery +{ + /** + * Set the field for this aggregation. + * + * @param string $field the name of the document field on which to perform this aggregation + * + * @return $this + */ + public function setField(string $field): self + { + return $this->setParam('field', $field); + } +} diff --git a/plugins/arSolrPlugin/lib/arSolrBoolQuery.class.php b/plugins/arSolrPlugin/lib/arSolrBoolQuery.class.php index c0b4bf6ad7..c1fa0fec14 100644 --- a/plugins/arSolrPlugin/lib/arSolrBoolQuery.class.php +++ b/plugins/arSolrPlugin/lib/arSolrBoolQuery.class.php @@ -22,6 +22,16 @@ */ class arSolrBoolQuery extends arSolrQuery { + public $queryBool; + + /** + * Constructor. + */ + public function __construct() + { + $this->queryBool = $this->setRawQuery(); + } + /** * Add should part to query. * @@ -43,7 +53,7 @@ public function addShould($args): self */ public function addMust($args): self { - return $this->_addQuery('must', $args); + return $this->_addQuery('must', (array) $args); } /** diff --git a/plugins/arSolrPlugin/lib/arSolrExists.class.php b/plugins/arSolrPlugin/lib/arSolrExistsQuery.class.php similarity index 94% rename from plugins/arSolrPlugin/lib/arSolrExists.class.php rename to plugins/arSolrPlugin/lib/arSolrExistsQuery.class.php index 6dbbdfe653..6eca63ed5e 100644 --- a/plugins/arSolrPlugin/lib/arSolrExists.class.php +++ b/plugins/arSolrPlugin/lib/arSolrExistsQuery.class.php @@ -18,9 +18,9 @@ */ /** - * arSolrExists. + * arSolrExistsQuery. */ -class arSolrExists extends arSolrQuery +class arSolrExistsQuery extends arSolrQuery { /** * Construct exists query. diff --git a/plugins/arSolrPlugin/lib/arSolrMatchAll.class.php b/plugins/arSolrPlugin/lib/arSolrMatchAllQuery.class.php similarity index 93% rename from plugins/arSolrPlugin/lib/arSolrMatchAll.class.php rename to plugins/arSolrPlugin/lib/arSolrMatchAllQuery.class.php index af684b15bd..fb4984332b 100644 --- a/plugins/arSolrPlugin/lib/arSolrMatchAll.class.php +++ b/plugins/arSolrPlugin/lib/arSolrMatchAllQuery.class.php @@ -18,9 +18,9 @@ */ /** - * arSolrMatchAll. + * arSolrMatchAllQuery. */ -class arSolrMatchAll extends arSolrQuery +class arSolrMatchAllQuery extends arSolrQuery { /** * Params. diff --git a/plugins/arSolrPlugin/lib/arSolrNestedQuery.class.php b/plugins/arSolrPlugin/lib/arSolrNestedQuery.class.php new file mode 100644 index 0000000000..4b64a2eae7 --- /dev/null +++ b/plugins/arSolrPlugin/lib/arSolrNestedQuery.class.php @@ -0,0 +1,116 @@ +. + */ + +/** + * arSolrNestedQuery. + */ +class arSolrNestedQuery extends arSolrQuery +{ + public $query; + + /** + * Constructor. + */ + public function __construct() + { + $this->query = $this->setRawQuery(); + } + + /** + * Adds field to mlt query. + * + * @param string $path Nested object path + * + * @return $this + */ + public function setPath(string $path): self + { + return $this->setParam('path', $path); + } + + /** + * Sets nested query. + * + * @return $this + */ + public function setQuery($query): self + { + return $this->setParam('query', $query); + } + + /** + * Set score method. + * + * @param string $scoreMode options: avg, total, max and none + * + * @return $this + */ + public function setScoreMode(string $scoreMode = 'avg'): self + { + return $this->setParam('score_mode', $scoreMode); + } + + /** + * + * + * @param string $ + * + * @return $this + */ + public function addSort() + { + return; + } + + /** + * + * + * @param string $ + * + * @return $this + */ + public function setSort() + { + return; + } + + /** + * + * + * @param string $ + * + * @return $this + */ + public function setTerm() + { + return; + } + + /** + * + * + * @param string $ + * + * @return $this + */ + public function setFilter() + { + return; + } +} diff --git a/plugins/arSolrPlugin/lib/arSolrPlugin.class.php b/plugins/arSolrPlugin/lib/arSolrPlugin.class.php index 4bb1bde3da..8bd1ca8a15 100644 --- a/plugins/arSolrPlugin/lib/arSolrPlugin.class.php +++ b/plugins/arSolrPlugin/lib/arSolrPlugin.class.php @@ -446,7 +446,7 @@ private function setType($type) return $type; } - private function getFieldQuery($field, $type, $multiValue, $includeInCopy = true, $stored = true) + private function getFieldQuery($field, $type, $multiValue, $stored = true, $includeInCopy = true) { $stored = $stored ? 'true' : 'false'; $multiValue = $multiValue ? 'true' : 'false'; diff --git a/plugins/arSolrPlugin/lib/arSolrPluginQuery.class.php b/plugins/arSolrPlugin/lib/arSolrPluginQuery.class.php new file mode 100644 index 0000000000..7415385851 --- /dev/null +++ b/plugins/arSolrPlugin/lib/arSolrPluginQuery.class.php @@ -0,0 +1,669 @@ +. + */ + +class arSolrPluginQuery extends arSolrQuery +{ + public $query; + public $queryBool; + public $filters; + public $criteria; + public $params; + + /** + * Constructor. + * + * @param int $limit how many results should be returned + * @param int $skip how many results should be skipped + */ + public function __construct($limit = 10, $skip = 0) + { + $this->query = new arSolrQueryClass(); + $this->setParam('size', $limit); + $this->setParam('from', $skip); + + $this->queryBool = new arSolrBoolQuery(); + } + + /** + * Translate internal representation of aggregations + * to Elastica API, adding them to the query. + * + * @param array $aggs search aggregations + */ + public function addAggs($aggs) + { + foreach ($aggs as $name => $item) { + switch ($item['type']) { + case 'term': + $agg = new arSolrAggregationTerms($name); + $agg->setField($item['field']); + + break; + + case 'filter': + $agg = new arSolrAggregationFilter($name); + $agg->setFilter(new arSolrTermQuery($item['field'])); + + break; + } + + // Sets the amount of terms to be returned + if (isset($item['size'])) { + $agg->setSize($item['size']); + } + + $this->query->addAggregation($agg); + } + } + + /** + * Add filters from aggregations to the query. + * + * @param array $aggs search aggregations + * @param array $params search filters from aggregations + */ + public function addAggFilters($aggs, $params) + { + $this->filters = []; + + // Filter languages only if the languages aggregation + // is being used and languages is set in the request + if (isset($aggs['languages'], $params['languages'])) { + $this->filters['languages'] = $params['languages']; + $term = new arSolrTermQuery( + [$aggs['languages']['field'] => $params['languages']] + ); + + $this->queryBool->addMust($term); + } + + // Add agg selections as search criteria + foreach ($params as $param => $value) { + if ( + 'languages' == $param + || !array_key_exists($param, $aggs) + || ('repos' == $param && (!ctype_digit($value) + || null === QubitRepository::getById($value))) + || 1 === preg_match('/^[\s\t\r\n]*$/', $value) + ) { + continue; + } + + $this->filters[$param] = $value; + + $query = new arSolrTermQuery( + [$aggs[$param]['field'] => $value] + ); + + // Collection agg must select all descendants and itself + if ('collection' == $param) { + $collection = QubitInformationObject::getById($value); + + $querySelf = new arSolrMatchQuery(); + $querySelf->setFieldQuery('slug', $collection->slug); + + $queryBool = new arSolrBoolQuery(); + $queryBool->addShould($query); + $queryBool->addShould($querySelf); + + $query = $queryBool; + } + + $this->queryBool->addMust($query); + } + } + + /** + * Add criteria to query based on advanced search form and other params. + * + * @param mixed $fieldNames + * @param mixed $params + * @param mixed $archivalStandard + */ + public function addAdvancedSearchFilters( + $fieldNames, $params, $archivalStandard + ) { + // Build query with the boolean criteria + if ( + null !== $criteria = $this->parseQuery($params, $archivalStandard) + ) { + $this->queryBool->addMust($criteria); + } + + // Process advanced search form fields + // Some of them have the same name as a aggregation, this creates query + // duplication but allows as to keep aggs and adv. search form + // synchronized + foreach ($fieldNames as $name) { + if ( + isset($params[$name]) + && strlen(trim($params[$name])) > 0 + && ( + null !== $criteria = $this->fieldCriteria( + $name, $params[$name] + ) + ) + ) { + $this->queryBool->addMust($criteria); + } + } + + if (null !== $criteria = $this->getDateRangeQuery($params)) { + $this->queryBool->addMust($criteria); + } + + // Default to show only top level descriptions + if ( + 'isaar' != $archivalStandard + && ( + !isset($params['topLod']) + || filter_var($params['topLod'], FILTER_VALIDATE_BOOLEAN) + ) + ) { + $this->queryBool->addMust( + new arSolrTermQuery( + ['parentId' => QubitInformationObject::ROOT_ID] + ) + ); + } + + // Show descriptions related to an actor by an event type, + // this parameters come from the actor related IOs lists + if ( + isset($params['actorId']) + && ctype_digit($params['actorId']) + && isset($params['eventTypeId']) + && ctype_digit($params['eventTypeId']) + ) { + $queryBool = new arSolrBoolQuery(); + $queryBool->addMust( + new arSolrTermQuery( + ['dates.actorId' => $params['actorId']] + ) + ); + $queryBool->addMust( + new arSolrTermQuery( + ['dates.typeId' => $params['eventTypeId']] + ) + ); + + // Use nested query and mapping object to allow querying + // over the actor and event ids from the same event + $queryNested = new arSolrNestedQuery(); + $queryNested->setPath('dates'); + $queryNested->setQuery($queryBool); + + $this->queryBool->addMust($queryNested); + } + + // Show descendants from resource + if (isset($params['ancestor']) && ctype_digit($params['ancestor'])) { + $this->queryBool->addMust( + new arSolrTermQuery(['ancestors' => $params['ancestor']]) + ); + } + } + + /** + * Returns the query. + * + * @param bool $allowEmpty get all or none if the query is empty + * @param bool $filterDrafts filter draft records + * + * @return \Elastica\Query + */ + public function getQuery($allowEmpty = false, $filterDrafts = false) + { + if (!$allowEmpty && 1 > count($this->queryBool->getParams())) { + $this->queryBool->addMust(new arSolrMatchAllQuery()); + } + + if ($filterDrafts) { + QubitSolrAclSearch::filterDrafts($this->queryBool); + } + + $this->query->setQuery($this->queryBool); + + return $this->query; + } + + /** + * Translate array of search parameters to query criteria. + * + * Modified version of parseQuery method in the SearchAdvancedAction class + * + * Each set of parameters is numbered, starting at zero, and includes three + * properties: query text (prefixed by "sq"), operation (prefixed by "so": + * "and" or "or"), and fields (prefixed by "sf") to return (defaulting to + * "_all"). + * + * For example: + * + * $this->searchParams = array( + * 'so0' => 'and', + * 'sq0' => 'cats', + * 'sf0' => '' + * ); + * + * @param mixed $params + * @param mixed $archivalStandard + * + * @return object arSolrBoolQuery instance + */ + protected function parseQuery($params, $archivalStandard) + { + $this->criteria = []; + $queryBool = new arSolrBoolQuery(); + $count = 0; + + while (isset($params['sq'.$count])) { + $query = $params['sq'.$count]; + + if (!empty($query)) { + $field = '_all'; + if (!empty($params['sf'.$count])) { + $field = $params['sf'.$count]; + } + + $operator = 'and'; + if (!empty($params['so'.$count])) { + $operator = $params['so'.$count]; + } + + $queryField = $this->queryField( + $field, $query, $archivalStandard + ); + $this->addToQueryBool($queryBool, $operator, $queryField); + + $this->criteria[] = [ + 'query' => $query, + 'field' => $field, + 'operator' => $operator, + ]; + } + + ++$count; + } + + if (0 == count($queryBool->getParams())) { + return; + } + + return $queryBool; + } + + + /** + * Constructor. + * + * @param string $field + * @param string $query + * @param string $archivalStandard + * + * @return array + */ + protected function queryField($field, $query, $archivalStandard) + { + switch ($field) { + case 'identifier': + case 'referenceCode': + case 'descriptionIdentifier': + $fields = [$field => 1]; + + break; + + case 'title': + case 'scopeAndContent': + case 'extentAndMedium': + case 'authorizedFormOfName': + case 'datesOfExistence': + case 'history': + case 'legalStatus': + case 'generalContext': + case 'institutionResponsibleIdentifier': + case 'sources': + case 'places': + $fields = ['i18n.%s.'.$field => 1]; + + break; + + case 'archivalHistory': + ProjectConfiguration::getActive()->loadHelpers( + ['Asset', 'Qubit'] + ); + + // Check archival history visibility + if ( + ('rad' == $archivalStandard && !check_field_visibility('app_element_visibility_rad_archival_history')) + || ('isad' == $archivalStandard && !check_field_visibility('app_element_visibility_isad_archival_history')) + ) { + return; + } + + $fields = ['i18n.%s.archivalHistory' => 1]; + + break; + + case 'genre': + $fields = ['genres.i18n.%s.name' => 1]; + + break; + + case 'subject': + $fields = ['subjects.i18n.%s.name' => 1]; + + break; + + case 'name': + $fields = ['names.i18n.%s.authorizedFormOfName' => 1]; + + break; + + case 'creator': + $fields = [ + 'creators.i18n.%s.authorizedFormOfName' => 1, + 'inheritedCreators.i18n.%s.authorizedFormOfName' => 1, + ]; + + break; + + case 'place': + $fields = [ + 'places.i18n.%s.name' => 1, + 'places.useFor.i18n.%s.name' => 1, + ]; + + break; + + case 'findingAidTranscript': + $fields = ['findingAid.transcript' => 1]; + + break; + + case 'digitalObjectTranscript': + $fields = ['transcript' => 1]; + + break; + + case 'allExceptFindingAidTranscript': + $fields = arSolrPluginUtil::getAllFields( + 'informationObject', + ['findingAid.transcript'] + ); + + break; + + case 'parallelNames': + case 'otherNames': + case 'occupations': + $fields = [$field.'.i18n.%s.name' => 1]; + + break; + + case 'occupationNotes': + $fields = ['occupations.i18n.%s.content' => 1]; + + break; + + case 'maintenanceNotes': + $fields = ['maintenanceNotes.i18n.%s.content' => 1]; + + break; + + case '_all': + default: + if ('isaar' == $archivalStandard) { + $documentType = 'actor'; + } else { + $documentType = 'informationObject'; + } + + $fields = arSolrPluginUtil::getAllFields($documentType); + + break; + } + + return arSolrPluginUtil::generateQueryString( + $query, $fields + ); + } + + protected function addToQueryBool(&$queryBool, $operator, $queryField) + { + switch ($operator) { + case 'not': + $queryBool->addMustNot($queryField); + + break; + + case 'or': + // Build boolean query with all the previous queries + // and the new one as 'shoulds' + $queryOr = new arSolrBoolQuery(); + $queryOr->addShould($queryBool); + $queryOr->addShould($queryField); + + $queryBool = new arSolrBoolQuery(); + $queryBool->addMust($queryOr); + + break; + + case 'and': + default: // First criteria falls here + $queryBool->addMust($queryField); + + break; + } + } + + protected function fieldCriteria($name, $value) + { + switch ($name) { + case 'copyrightStatus': + // Get unknown copyright status term + $criteria = new Criteria(); + $criteria->addJoin(QubitTerm::ID, QubitTermI18n::ID); + $criteria->add( + QubitTerm::TAXONOMY_ID, QubitTaxonomy::COPYRIGHT_STATUS_ID + ); + $criteria->add(QubitTermI18n::NAME, 'Unknown'); + $term = QubitTerm::getOne($criteria); + + // If the user selected "Unknown copyright" make sure that we + // are matching documents that either (1) copyright status is + // unknown or (2) copyright status is not set. + if (isset($term) && $term->id == $value) { + // Query for documents without copyright status + $exists = new arSolrExistsQuery('copyrightStatusId'); + $queryBoolMissing = new arSolrBoolQuery(); + $queryBoolMissing->addMustNot($exists); + + // Query for unknown copyright status + $query = new arSolrTermQuery(); + $query->setTerm('copyrightStatusId', $value); + + $queryBool = new arSolrBoolQuery(); + $queryBool->addShould($query); + $queryBool->addShould($queryBoolMissing); + + return $queryBool; + } + + $query = new arSolrTermQuery(); + $query->setTerm('copyrightStatusId', $value); + + return $query; + + case 'onlyMedia': + $query = new arSolrTermQuery(); + $query->setTerm('hasDigitalObject', filter_var( + $value, FILTER_VALIDATE_BOOLEAN) + ); + + return $query; + + case 'materialType': + $query = new arSolrTermQuery(); + $query->setTerm('materialTypeId', $value); + + return $query; + + case 'findingAidStatus': + switch ($value) { + case 'yes': + $query = new arSolrExistsQuery( + 'findingAid.status' + ); + + return $query; + + case 'no': + $exists = new arSolrExistsQuery( + 'findingAid.status' + ); + $query = new arSolrBoolQuery(); + $query->addMustNot($exists); + + return $query; + + case 'generated': + $query = new arSolrTermQuery(); + $query->setTerm( + 'findingAid.status', + QubitFindingAid::GENERATED_STATUS + ); + + return $query; + + case 'uploaded': + $query = new arSolrTermQuery(); + $query->setTerm( + 'findingAid.status', + QubitFindingAid::UPLOADED_STATUS + ); + + return $query; + } + + return; + } + } + + /* + * Greate date range boolean query based on the dates and type. + * Types: + * - 'inclusive': the event must be active inside the range (it may overlap) + * - 'exact' (or others): the event must be active only inside range + */ + protected function getDateRangeQuery($params) + { + if (empty($params['startDate']) && empty($params['endDate'])) { + return; + } + + // Process date range, defaults to inclusive + $type = $params['rangeType']; + if (empty($type)) { + $type = 'inclusive'; + } + + $query = new arSolrBoolQuery(); + $range = []; + + if (!empty($params['startDate'])) { + $range['gte'] = $params['startDate']; + + if ('inclusive' == $type) { + // Start date before range and end date missing + $queryBool = new arSolrBoolQuery(); + $start = new arSolrRangeQuery( + 'dates.startDate', + ['lt' => $params['startDate']] + ); + $exists = new arSolrExistsQuery('dates.endDate'); + $queryBool->addMust($start); + $queryBool->addMustNot($exists); + + $query->addShould($queryBool); + } + } + + if (!empty($params['endDate'])) { + $range['lte'] = $params['endDate']; + + if ('inclusive' == $type) { + // End date after range and start date missing + $queryBool = new arSolrBoolQuery(); + $end = new arSolrRangeQuery( + 'dates.endDate', ['gt' => $params['endDate']] + ); + $exists = new arSolrExistsQuery('dates.startDate'); + $queryBool->addMust($end); + $queryBool->addMustNot($exists); + + $query->addShould($queryBool); + } + } + + if ( + !empty($params['startDate']) + && !empty($params['endDate']) + && 'inclusive' == $type + ) { + // Start date before range and end date after range + $queryBool = new arSolrBoolQuery(); + $queryBool->addMust( + new arSolrRangeQuery( + 'dates.startDate', ['lt' => $params['startDate']] + ) + ); + $queryBool->addMust( + new arSolrRangeQuery( + 'dates.endDate', ['gt' => $params['endDate']] + ) + ); + + $query->addShould($queryBool); + } + + if ('inclusive' == $type) { + // Any event date inside the range + $query->addShould( + new arSolrRangeQuery('dates.startDate', $range) + ); + $query->addShould( + new arSolrRangeQuery('dates.endDate', $range) + ); + } else { + // Both event dates inside the range + $query->addMust( + new arSolrRangeQuery('dates.startDate', $range) + ); + $query->addMust(new arSolrRangeQuery('dates.endDate', $range)); + } + + // Use nested query and mapping object to allow querying + // over the start and end dates from the same event + $queryNested = new arSolrNestedQuery(); + $queryNested->setPath('dates'); + $queryNested->setQuery($query); + + return $queryNested; + } +} diff --git a/plugins/arSolrPlugin/lib/arSolrPluginUtil.class.php b/plugins/arSolrPlugin/lib/arSolrPluginUtil.class.php index b38bbab2f8..ddd3b8fad5 100644 --- a/plugins/arSolrPlugin/lib/arSolrPluginUtil.class.php +++ b/plugins/arSolrPlugin/lib/arSolrPluginUtil.class.php @@ -184,8 +184,7 @@ public static function getI18nFieldNames($fields, $cultures = null) public static function generateQueryString( $query, $fields, $operator = 'AND' ) { - // TODO - CONVERT TO SOLR QUERY STRING - $queryString = new \Elastica\Query\QueryString(self::escapeTerm($query)); + $queryString = new arSolrQueryString(self::escapeTerm($query)); $queryString->setDefaultOperator($operator); $queryString->setFields(self::getBoostedSearchFields($fields)); $queryString->setAnalyzer( diff --git a/plugins/arSolrPlugin/lib/arSolrQuery.class.php b/plugins/arSolrPlugin/lib/arSolrQuery.class.php index b8ea843b54..c39d0fbd45 100644 --- a/plugins/arSolrPlugin/lib/arSolrQuery.class.php +++ b/plugins/arSolrPlugin/lib/arSolrQuery.class.php @@ -153,6 +153,25 @@ public function generateQueryParams() ]; } + /** + * Adds a single param or an array of params to the list. + * + * @param string $key Param key + * @param mixed $value Value to set + * + * @return $this + */ + public function addParam($key, $value, ?string $subKey = null) + { + if (null !== $subKey) { + $this->params[$key][$subKey] = $value; + } else { + $this->params[$key][] = $value; + } + + return $this; + } + /** * Sets (overwrites) the value at the given key. * @@ -183,20 +202,44 @@ public function setParams(array $params) } /** - * Adds a single param or an array of params to the list. - * - * @param string $key Param key - * @param mixed $value Value to set + * Returns the params array. * - * @return $this + * @return array Params */ - public function addParam($key, $value, ?string $subKey = null) + public function getParams() { - if (null !== $subKey) { - $this->params[$key][$subKey] = $value; - } else { - $this->params[$key][] = $value; - } + return $this->params; + } + + /** + * Sets query as raw array. Will overwrite all already set arguments. + */ + public function setRawQuery(array $query = []): self + { + $this->params = $query; + + return $this; + } + + /** + * Sets a post_filter to the current query. + */ + public function setPostFilter($filter): self + { + return $this->setParam('post_filter', $filter); + } + + public function setQuery($query): self + { + return $this->setParam('query', $query); + } + + /** + * Adds an Aggregation to the query. + */ + public function addAggregation($agg): self + { + $this->params['aggs'][] = $agg; return $this; } diff --git a/plugins/arSolrPlugin/lib/arSolrQueryClass.class.php b/plugins/arSolrPlugin/lib/arSolrQueryClass.class.php new file mode 100644 index 0000000000..1114c40298 --- /dev/null +++ b/plugins/arSolrPlugin/lib/arSolrQueryClass.class.php @@ -0,0 +1,70 @@ +. + */ + +/** + * arSolrQueryClass. + */ +class arSolrQueryClass extends arSolrQuery +{ + /** + * Creates a query object. + * + * @param AbstractQuery|array|Collapse|Suggest $query Query object (default = null) + * + * @phpstan-param AbstractQuery|Suggest|Collapse|TRawQuery $query + */ + public function __construct($query = null) + { + if (\is_array($query)) { + $this->setRawQuery($query); + } else { + $this->setQuery($query); + } + } + + /** + * Adds a sort param to the query. + * + * @param mixed $sort Sort parameter + * + * @phpstan-param TSortArg $sort + * + * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/search-request-sort.html + */ + public function addSort($sort): self + { + return $this->addParam('sort', $sort); + } + + /** + * Sets sort arguments for the query + * Replaces existing values. + * + * @param array $sortArgs Sorting arguments + * + * @phpstan-param TSortArgs $sortArgs + * + * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/search-request-sort.html + */ + public function setSort(array $sortArgs): self + { + return $this->setParam('sort', $sortArgs); + } + +} diff --git a/plugins/arSolrPlugin/lib/arSolrQueryIds.class.php b/plugins/arSolrPlugin/lib/arSolrQueryIds.class.php new file mode 100644 index 0000000000..b55e06dbb5 --- /dev/null +++ b/plugins/arSolrPlugin/lib/arSolrQueryIds.class.php @@ -0,0 +1,74 @@ +. + */ + +/** + * arSolrQueryIds. + */ +class arSolrQueryIds extends arSolrQuery +{ + /** + * Creates filter object. + * + * @param array $ids List of ids + */ + public function __construct(array $ids = []) + { + $this->setIds($ids); + } + + /** + * Adds one more filter to the and filter. + * + * @param string $id Adds id to filter + * + * @return $this + */ + public function addId(string $id): self + { + $this->params['values'][] = $id; + + return $this; + } + + /** + * Sets the ids to filter. + * + * @param array|string $ids List of ids + * + * @return $this + */ + public function setIds($ids): self + { + if (\is_array($ids)) { + $this->params['values'] = $ids; + } else { + $this->params['values'] = [$ids]; + } + + return $this; + } + + /** + * {@inheritdoc} + */ + public function toArray(): array + { + return ['ids' => $this->params]; + } +} diff --git a/plugins/arSolrPlugin/lib/arSolrQueryString.class.php b/plugins/arSolrPlugin/lib/arSolrQueryString.class.php new file mode 100644 index 0000000000..e0d2038c73 --- /dev/null +++ b/plugins/arSolrPlugin/lib/arSolrQueryString.class.php @@ -0,0 +1,232 @@ +. + */ + +/** + * arSolr. + */ +class arSolrQueryString extends arSolrQuery +{ + /** + * Query string. + * + * @var string Query string + */ + protected $_queryString; + + /** + * Creates query string object. Calls setQuery with argument. + * + * @param string $queryString OPTIONAL Query string for object + */ + public function __construct(string $queryString = '') + { + $this->setQuery($queryString); + } + + /** + * Sets the default field. + * You cannot set fields and default_field. + * + * If no field is set, _all is chosen + * + * @param string $field Field + * + * @return $this + */ + public function setDefaultField(string $field): self + { + return $this->setParam('default_field', $field); + } + + /** + * Sets the default operator AND or OR. + * + * If no operator is set, OR is chosen + * + * @param string $operator Operator + * + * @return $this + */ + public function setQueryDefaultOperator(string $operator = 'or'): self + { + return $this->setParam('default_operator', $operator); + } + + /** + * Sets the analyzer to analyze the query with. + * + * @param string $analyzer Analyser to use + * + * @return $this + */ + public function setAnalyzer(string $analyzer): self + { + return $this->setParam('analyzer', $analyzer); + } + + /** + * Sets the parameter to allow * and ? as first characters. + * + * If not set, defaults to true. + * + * @return $this + */ + public function setAllowLeadingWildcard(bool $allow = true): self + { + return $this->setParam('allow_leading_wildcard', $allow); + } + + /** + * Sets the parameter to enable the position increments in result queries. + * + * If not set, defaults to true. + * + * @return $this + */ + public function setEnablePositionIncrements(bool $enabled = true): self + { + return $this->setParam('enable_position_increments', $enabled); + } + + /** + * Sets the fuzzy prefix length parameter. + * + * If not set, defaults to 0. + * + * @return $this + */ + public function setFuzzyPrefixLength(int $length = 0): self + { + return $this->setParam('fuzzy_prefix_length', $length); + } + + /** + * Sets the fuzzy minimal similarity parameter. + * + * If not set, defaults to 0.5 + * + * @return $this + */ + public function setFuzzyMinSim(float $minSim = 0.5): self + { + return $this->setParam('fuzzy_min_sim', $minSim); + } + + /** + * Sets the phrase slop. + * + * If zero, exact phrases are required. + * If not set, defaults to zero. + * + * @return $this + */ + public function setPhraseSlop(int $phraseSlop = 0): self + { + return $this->setParam('phrase_slop', $phraseSlop); + } + + /** + * Sets the boost value of the query. + * + * If not set, defaults to 1.0. + * + * @return $this + */ + public function setBoost(float $boost = 1.0): self + { + return $this->setParam('boost', $boost); + } + + /** + * Allows analyzing of wildcard terms. + * + * If not set, defaults to true + * + * @return $this + */ + public function setAnalyzeWildcard(bool $analyze = true): self + { + return $this->setParam('analyze_wildcard', $analyze); + } + + /** + * Sets the fields. If no fields are set, _all is chosen. + * You cannot set fields and default_field. + * + * @param array $fields Fields + * + * @return $this + */ + public function setQueryFields(array $fields): self + { + return $this->setParam('fields', $fields); + } + + /** + * Whether to use bool or dis_max queries to internally combine results for multi field search. + * + * @param bool $value Determines whether to use + * + * @return $this + */ + public function setUseDisMax(bool $value = true): self + { + return $this->setParam('use_dis_max', $value); + } + + /** + * When using dis_max, the disjunction max tie breaker. + * + * If not set, defaults to 0.0. + * + * @return $this + */ + public function setTieBreaker(float $tieBreaker = 0.0): self + { + return $this->setParam('tie_breaker', $tieBreaker); + } + + /** + * Set a re-write condition. See https://github.com/elasticsearch/elasticsearch/issues/1186 for additional information. + * + * @return $this + */ + public function setRewrite(string $rewrite = ''): self + { + return $this->setParam('rewrite', $rewrite); + } + + /** + * Set timezone option. + * + * @return $this + */ + public function setTimezone(string $timezone): self + { + return $this->setParam('time_zone', $timezone); + } + + /** + * {@inheritdoc} + */ + public function toArray(): array + { + return ['query_string' => \array_merge(['query' => $this->_queryString], $this->getParams())]; + } +} diff --git a/plugins/arSolrPlugin/lib/arSolrRange.class.php b/plugins/arSolrPlugin/lib/arSolrRangeQuery.class.php similarity index 95% rename from plugins/arSolrPlugin/lib/arSolrRange.class.php rename to plugins/arSolrPlugin/lib/arSolrRangeQuery.class.php index aeca050226..d63994bb41 100644 --- a/plugins/arSolrPlugin/lib/arSolrRange.class.php +++ b/plugins/arSolrPlugin/lib/arSolrRangeQuery.class.php @@ -18,9 +18,9 @@ */ /** - * arSolrRange. + * arSolrRangeQuery. */ -class arSolrRange extends arSolrQuery +class arSolrRangeQuery extends arSolrQuery { /** * Constructor. diff --git a/plugins/arSolrPlugin/lib/arSolrTerm.class.php b/plugins/arSolrPlugin/lib/arSolrTermQuery.class.php similarity index 96% rename from plugins/arSolrPlugin/lib/arSolrTerm.class.php rename to plugins/arSolrPlugin/lib/arSolrTermQuery.class.php index aeb0400ba8..b392c8ad46 100644 --- a/plugins/arSolrPlugin/lib/arSolrTerm.class.php +++ b/plugins/arSolrPlugin/lib/arSolrTermQuery.class.php @@ -18,9 +18,9 @@ */ /** - * arSolrTerm. + * arSolrTermQuery. */ -class arSolrTerm extends arSolrQuery +class arSolrTermQuery extends arSolrQuery { /** * Calls setTerm with the given $term array diff --git a/plugins/qbAclPlugin/lib/QubitSolrAclSearch.class.php b/plugins/qbAclPlugin/lib/QubitSolrAclSearch.class.php new file mode 100644 index 0000000000..3aa76661c2 --- /dev/null +++ b/plugins/qbAclPlugin/lib/QubitSolrAclSearch.class.php @@ -0,0 +1,182 @@ +. + */ + +/** + * Filter search query objects based in the access lists. The queries are + * instances of the \Elastica\Query class. + */ +class QubitSolrAclSearch +{ + /** + * Filter search query by repository. + * + * @param \Elastica\Query $query Search query object + * @param string $action Action + * + * @return \Elastica\Query Filtered query + */ + public static function filterByRepository(arSolrPluginQuery $query, $action) + { + $repositoryAccess = QubitAcl::getRepositoryAccess($action); + if (1 == count($repositoryAccess)) { + // If all repositories are denied access, re-route user to login + if (QubitAcl::DENY == $repositoryAccess[0]['access']) { + QubitAcl::forwardUnauthorized(); + } + } else { + while ($repo = array_shift($repositoryAccess)) { + if ('*' == $repo['id']) { + $queryBool = new arSolrBoolQuery(); + + $query = new arSolrTermQuery(); + $query->setTerm('repositoryId', $repo['id']); + + if (QubitAcl::DENY == $repo['access']) { + // Require repos to be specifically allowed (all others prohibited) + // (ZSL) $query->addSubquery(QubitSearch::getInstance()->addTerm($repo['id'], 'repositoryId'), true); + $queryBool->addMust($query); + } else { + // Prohibit specified repos (all others allowed) + // (ZSL) $query->addSubquery(QubitSearch::getInstance()->addTerm($repo['id'], 'repositoryId'), false); + $queryBool->addMustNot($query); + } + + $query->setPostFilter($queryBool); + } + } + } + + return $query; + } + + /** + * Filter search query by resource specific ACL. + * + * @param \Elastica\Query $query Search query object + * @param mixed $root Root object for list + * + * @return \Elastica\Query Filtered query + */ + public static function filterByResource(arSolrPluginQuery $query, $root) + { + $user = sfContext::getInstance()->user; + + $permissions = QubitAcl::getUserPermissionsByAction($user, get_class($root), 'read'); + + // Build access control list + $grants = 0; + if (0 < count($permissions)) { + foreach ($permissions as $permission) { + if (!isset($resourceAccess[$permission->objectId])) { + $resourceAccess[$permission->objectId] = QubitAcl::isAllowed($user, $permission->objectId, 'read'); + + if ($resourceAccess[$permission->objectId]) { + ++$grants; + } + } + } + } + + // If no grants then user can't see anything + if (0 == $grants) { + QubitAcl::forwardUnauthorized(); + } + + // If global deny is default, then list allowed resources + elseif (!QubitAcl::isAllowed($user, $root->id, 'read')) { + $allows = array_keys($resourceAccess, true, true); + + $ids = []; + while ($resourceId = array_shift($allows)) { + // (ZSL) $query->addSubquery(QubitSearch::getInstance()->addTerm($resourceId, 'id'), true); + $ids[] = $resourceId; + } + + if (0 < count($ids)) { + $queryIds = new arSolrQueryIds(); + $queryIds->setIds($ids); + + $query->setPostFilter($queryIds); + } + } + + // Otherwise, build a list of banned resources + else { + $bans = array_keys($resourceAccess, false, true); + + $ids = []; + while ($resourceId = array_shift($bans)) { + // (ZSL) $query->addSubquery(QubitSearch::getInstance()->addTerm($resourceId, 'id'), false); + $ids[] = $resourceId; + } + + if (0 < count($ids)) { + $queryIds = new arSolrQueryIds(); + $queryIds->setIds($ids); + + $queryBool = new arSolrBoolQuery(); + $queryBool->addMustNot($ids); + + $query->setPostFilter($queryIds); + } + } + + return $query; + } + + /** + * Filter search query by resource specific ACL. + * + * @param arSolrBoolQuery $queryBool Search query object + */ + public static function filterDrafts(arSolrBoolQuery $queryBool) + { + // Filter out 'draft' items by repository + $repositoryViewDrafts = QubitAcl::getRepositoryAccess('viewDraft'); + if (1 == count($repositoryViewDrafts)) { + if (QubitAcl::DENY == $repositoryViewDrafts[0]['access']) { + // Don't show *any* draft info objects + $query = new arSolrTermQuery(); + $query->setTerm('publicationStatusId', QubitTerm::PUBLICATION_STATUS_PUBLISHED_ID); + + $queryBool->addMust($query); + } + } else { + // Get last rule in list, it will be the global rule with the opposite + // access of the preceeding rules (e.g. if last rule is "DENY ALL" then + // preceeding rules will be "ALLOW" rules) + $globalRule = array_pop($repositoryViewDrafts); + + $query = new arSolrBoolQuery(); + + while ($repo = array_shift($repositoryViewDrafts)) { + $query->addShould(new arSolrTermQuery(['repository.id' => (int) $repo['id']])); + } + + $query->addShould(new arSolrTermQuery(['publicationStatusId' => QubitTerm::PUBLICATION_STATUS_PUBLISHED_ID])); + + // Does this ever happen in AtoM? + if (QubitAcl::GRANT == $globalRule['access']) { + $queryBool->addMustNot($query); + } else { + $queryBool->addMust($query); + } + } + } +}