Skip to content

Commit 7c58c9d

Browse files
max-zillabodom0015
andauthored
Allow sorting by metadata fields (#189)
* add some sorting logic (strings are broken) * Add better support for name sorting * update swagger and changelog * comment typo * remove 6.8 normalizer code * Update public/swagger.yml Co-authored-by: Mike Lambert <[email protected]> * dummy out name._sort sorting to avoid errors on old Dbs Co-authored-by: Mike Lambert <[email protected]>
1 parent 7d03641 commit 7c58c9d

File tree

7 files changed

+79
-17
lines changed

7 files changed

+79
-17
lines changed

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,12 @@ All notable changes to this project will be documented in this file.
44
The format is based on [Keep a Changelog](http://keepachangelog.com/)
55
and this project adheres to [Semantic Versioning](http://semver.org/).
66

7+
## Unreleased
8+
9+
### Added
10+
- Added a `sort` and `order` parameter to `/api/search` endpoint that supports date and numeric field sorting. If only order is specified, created date is used. String fields are not currently supported.
11+
- Added a new `/api/deleteindex` admin endpoint that will queue an action to delete an Elasticsearch index (usually prior to a reindex).
12+
713
## 1.15.1 - 2021-03-12
814

915
### Fixed

app/api/Admin.scala

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,4 +180,10 @@ class Admin @Inject() (userService: UserService,
180180
if (success) Ok(toJson(Map("status" -> "reindex successfully queued")))
181181
else BadRequest(toJson(Map("status" -> "reindex queuing failed, Elasticsearch may be disabled")))
182182
}
183+
184+
def deleteIndex = ServerAdminAction { implicit request =>
185+
val success = esqueue.queue("delete_index")
186+
if (success) Ok(toJson(Map("status" -> "deindex successfully queued")))
187+
else BadRequest(toJson(Map("status" -> "deindex queuing failed, Elasticsearch may be disabled")))
188+
}
183189
}

app/api/Search.scala

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ class Search @Inject() (
2222
/** Search using a simple text string with filters */
2323
def search(query: String, resource_type: Option[String], datasetid: Option[String], collectionid: Option[String],
2424
spaceid: Option[String], folderid: Option[String], field: Option[String], tag: Option[String],
25-
from: Option[Int], size: Option[Int], page: Option[Int]) = PermissionAction(Permission.ViewDataset) { implicit request =>
25+
from: Option[Int], size: Option[Int], page: Option[Int], sort: Option[String], order: Option[String]) = PermissionAction(Permission.ViewDataset) { implicit request =>
2626
current.plugin[ElasticsearchPlugin] match {
2727
case Some(plugin) => {
2828
// If from is specified, use it. Otherwise use page * size of page if possible, otherwise use 0.
@@ -42,7 +42,9 @@ class Search @Inject() (
4242
(spaceid match {case Some(x) => s"&spaceid=$x" case None => ""}) +
4343
(folderid match {case Some(x) => s"&folderid=$x" case None => ""}) +
4444
(field match {case Some(x) => s"&field=$x" case None => ""}) +
45-
(tag match {case Some(x) => s"&tag=$x" case None => ""})
45+
(tag match {case Some(x) => s"&tag=$x" case None => ""}) +
46+
(sort match {case Some(x) => s"&sort=$x" case None => ""}) +
47+
(order match {case Some(x) => s"&order=$x" case None => ""})
4648

4749
// Add space filter to search here as a simple permissions check
4850
val superAdmin = request.user match {
@@ -54,7 +56,7 @@ class Search @Inject() (
5456
else
5557
spaces.listAccess(0, Set[Permission](Permission.ViewSpace), request.user, true, true, false, false).map(sp => sp.id)
5658

57-
val response = plugin.search(query, resource_type, datasetid, collectionid, spaceid, folderid, field, tag, from_index, size, permitted, request.user)
59+
val response = plugin.search(query, resource_type, datasetid, collectionid, spaceid, folderid, field, tag, from_index, size, sort, order, permitted, request.user)
5860
val result = SearchUtils.prepareSearchResponse(response, source_url, request.user)
5961
Ok(toJson(result))
6062
}

app/services/ElasticsearchPlugin.scala

Lines changed: 40 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import play.api.libs.json._
2929
import _root_.util.SearchUtils
3030
import org.apache.commons.lang.StringUtils
3131
import org.elasticsearch.action.admin.indices.exists.indices.IndicesExistsRequest
32+
import org.elasticsearch.search.sort.SortOrder
3233

3334

3435
/**
@@ -130,7 +131,8 @@ class ElasticsearchPlugin(application: Application) extends Plugin {
130131
* "field_leaf_key": name of immediate field only, e.g. 'lines'
131132
*/
132133
val queryObj = prepareElasticJsonQuery(query, grouping, permitted, user)
133-
accumulatePageResult(queryObj, user, from.getOrElse(0), size.getOrElse(maxResults))
134+
// TODO: Support sorting in GUI search
135+
accumulatePageResult(queryObj, user, from.getOrElse(0), size.getOrElse(maxResults), None, None)
134136
}
135137

136138
/**
@@ -152,8 +154,8 @@ class ElasticsearchPlugin(application: Application) extends Plugin {
152154
*/
153155
def search(query: String, resource_type: Option[String], datasetid: Option[String], collectionid: Option[String],
154156
spaceid: Option[String], folderid: Option[String], field: Option[String], tag: Option[String],
155-
from: Option[Int], size: Option[Int], permitted: List[UUID], user: Option[User],
156-
index: String = nameOfIndex): ElasticsearchResult = {
157+
from: Option[Int], size: Option[Int], sort: Option[String], order: Option[String], permitted: List[UUID],
158+
user: Option[User], index: String = nameOfIndex): ElasticsearchResult = {
157159

158160
// Convert any parameters from API into the query syntax equivalent so we can parse it all together later
159161
var expanded_query = query
@@ -166,16 +168,16 @@ class ElasticsearchPlugin(application: Application) extends Plugin {
166168
folderid.foreach(fid => expanded_query += s" in:$fid")
167169

168170
val queryObj = prepareElasticJsonQuery(expanded_query.stripPrefix(" "), permitted, user)
169-
accumulatePageResult(queryObj, user, from.getOrElse(0), size.getOrElse(maxResults))
171+
accumulatePageResult(queryObj, user, from.getOrElse(0), size.getOrElse(maxResults), sort, order)
170172
}
171173

172174
/** Perform search, check permissions, and keep searching again if page isn't filled with permitted resources */
173175
def accumulatePageResult(queryObj: XContentBuilder, user: Option[User], from: Int, size: Int,
174-
index: String = nameOfIndex): ElasticsearchResult = {
176+
sort: Option[String], order: Option[String], index: String = nameOfIndex): ElasticsearchResult = {
175177
var total_results = ListBuffer.empty[ResourceRef]
176178

177179
// Fetch initial page & filter by permissions
178-
val (results, total_size) = _search(queryObj, index, Some(from), Some(size))
180+
val (results, total_size) = _search(queryObj, index, Some(from), Some(size), sort, order)
179181
Logger.debug(s"Found ${results.length} results with ${total_size} total")
180182
val filtered = checkResultPermissions(results, user)
181183
Logger.debug(s"Permission to see ${filtered.length} results")
@@ -187,7 +189,7 @@ class ElasticsearchPlugin(application: Application) extends Plugin {
187189
var exhausted = false
188190
while (total_results.length < size && !exhausted) {
189191
Logger.debug(s"Only have ${total_results.length} total results; searching for ${size*2} more from ${new_from}")
190-
val (results, total_size) = _search(queryObj, index, Some(new_from), Some(size*2))
192+
val (results, total_size) = _search(queryObj, index, Some(new_from), Some(size*2), sort, order)
191193
Logger.debug(s"Found ${results.length} results with ${total_size} total")
192194
if (results.length == 0 || new_from+results.length == total_size) exhausted = true // No more results to find
193195
val filtered = checkResultPermissions(results, user)
@@ -251,17 +253,39 @@ class ElasticsearchPlugin(application: Application) extends Plugin {
251253

252254
/*** Execute query and return list of results and total result count as tuple */
253255
def _search(queryObj: XContentBuilder, index: String = nameOfIndex,
254-
from: Option[Int] = Some(0), size: Option[Int] = Some(maxResults)): (List[ResourceRef], Long) = {
256+
from: Option[Int] = Some(0), size: Option[Int] = Some(maxResults),
257+
sort: Option[String], order: Option[String]): (List[ResourceRef], Long) = {
255258
connect()
256259
val response = client match {
257260
case Some(x) => {
258-
Logger.info("Searching Elasticsearch: "+queryObj.string())
261+
Logger.debug("Searching Elasticsearch: " + queryObj.string())
262+
263+
// Exclude _sort fields in response object
264+
var sortFilter = jsonBuilder().startObject().startArray("exclude").value("*._sort").endArray().endObject()
265+
259266
var responsePrep = x.prepareSearch(index)
260267
.setSearchType(SearchType.DFS_QUERY_THEN_FETCH)
268+
.setSource(sortFilter)
261269
.setQuery(queryObj)
262270

263271
responsePrep = responsePrep.setFrom(from.getOrElse(0))
264272
responsePrep = responsePrep.setSize(size.getOrElse(maxResults))
273+
// Default to ascending if no order provided but a field is
274+
val searchOrder = order match {
275+
case Some("asc") => SortOrder.ASC
276+
case Some("desc") => SortOrder.DESC
277+
case Some("DESC") => SortOrder.DESC
278+
case _ => SortOrder.ASC
279+
}
280+
// Default to created field if order is provided but no field is
281+
sort match {
282+
// case Some("name") => responsePrep = responsePrep.addSort("name._sort", searchOrder) TODO: Not yet supported
283+
case Some(x) => responsePrep = responsePrep.addSort(x, searchOrder)
284+
case None => order match {
285+
case Some(o) => responsePrep = responsePrep.addSort("created", searchOrder)
286+
case None => {}
287+
}
288+
}
265289

266290
val response = responsePrep.setExplain(true).execute().actionGet()
267291
Logger.debug("Search hits: " + response.getHits().getTotalHits())
@@ -291,8 +315,7 @@ class ElasticsearchPlugin(application: Application) extends Plugin {
291315
.field("type", "custom")
292316
.field("tokenizer", "uax_url_email")
293317
.endObject()
294-
.endObject()
295-
.endObject()
318+
.endObject().endObject()
296319
.startObject("index")
297320
.startObject("mapping")
298321
.field("ignore_malformed", true)
@@ -697,10 +720,14 @@ class ElasticsearchPlugin(application: Application) extends Plugin {
697720
* as strings for datatypes besides Objects. In the future, this could
698721
* be removed, but only once the Search API better supports those data types (e.g. Date).
699722
*/
723+
724+
// TODO: With Elastic 6.8+ we can use "normalizer": "case_insensitive" for _sort fields
725+
700726
"""{"clowder_object": {
701727
|"numeric_detection": true,
702728
|"properties": {
703-
|"name": {"type": "string"},
729+
|"name": {"type": "string", "fields": {
730+
| "_sort": {"type":"string", "index": "not_analyzed"}}},
704731
|"description": {"type": "string"},
705732
|"resource_type": {"type": "string", "include_in_all": false},
706733
|"child_of": {"type": "string", "include_in_all": false},
@@ -925,7 +952,7 @@ class ElasticsearchPlugin(application: Application) extends Plugin {
925952
}
926953
}
927954

928-
// If a term is specified that isn't in this list, it's assumed to be a metadata field
955+
// If a term is specified that isn't in this list, it's assumed to be a metadata field (for sorting and filtering)
929956
val official_terms = List("name", "creator", "created", "email", "resource_type", "in", "contains", "tag", "exists", "missing")
930957

931958
// Create list of (key, operator, value) for passing to builder

app/services/mongodb/ElasticsearchQueue.scala

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ class ElasticsearchQueue @Inject() (
5353
}
5454
}
5555
case "index_all" => _indexAll()
56+
case "delete_index" => _deleteIndex()
5657
case "index_swap" => _swapIndex()
5758
case _ => throw new IllegalArgumentException(s"Unrecognized action: ${action.action}")
5859
}
@@ -63,6 +64,7 @@ class ElasticsearchQueue @Inject() (
6364
case "index_dataset" => throw new IllegalArgumentException(s"No target specified for action ${action.action}")
6465
case "index_collection" => throw new IllegalArgumentException(s"No target specified for action ${action.action}")
6566
case "index_all" => _indexAll()
67+
case "delete_index" => _deleteIndex()
6668
case "index_swap" => _swapIndex()
6769
case _ => throw new IllegalArgumentException(s"Unrecognized action: ${action.action}")
6870
}
@@ -97,6 +99,12 @@ class ElasticsearchQueue @Inject() (
9799
})
98100
}
99101

102+
def _deleteIndex() = {
103+
current.plugin[ElasticsearchPlugin].foreach(p => {
104+
p.deleteAll()
105+
})
106+
}
107+
100108
// Replace the main index with the newly reindexed temp file
101109
def _swapIndex() = {
102110
Logger.debug("Swapping temporary reindex for main index")

conf/routes

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -310,6 +310,7 @@ POST /api/admin/users
310310
POST /api/sensors/config @api.Admin.sensorsConfig
311311
POST /api/changeAppearance @api.Admin.submitAppearance
312312
POST /api/reindex @api.Admin.reindex
313+
POST /api/deleteindex @api.Admin.deleteIndex
313314
POST /api/admin/configuration @api.Admin.updateConfiguration
314315

315316
#----------------------------------------------------------------------
@@ -663,7 +664,7 @@ DELETE /api/sections/:id
663664
# ----------------------------------------------------------------------
664665
GET /api/search/json @api.Search.searchJson(query: String ?= "", grouping: String ?= "AND", from: Option[Int], size: Option[Int])
665666
GET /api/search/multimediasearch @api.Search.searchMultimediaIndex(section_id: UUID)
666-
GET /api/search @api.Search.search(query: String ?= "", resource_type: Option[String], datasetid: Option[String], collectionid: Option[String], spaceid: Option[String], folderid: Option[String], field: Option[String], tag: Option[String], from: Option[Int], size: Option[Int], page: Option[Int])
667+
GET /api/search @api.Search.search(query: String ?= "", resource_type: Option[String], datasetid: Option[String], collectionid: Option[String], spaceid: Option[String], folderid: Option[String], field: Option[String], tag: Option[String], from: Option[Int], size: Option[Int], page: Option[Int], sort: Option[String], order: Option[String])
667668

668669
# ----------------------------------------------------------------------
669670
# GEOSTREAMS ENDPOINT

public/swagger.yml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,18 @@ paths:
150150
assuming "size" items per page.
151151
schema:
152152
type: integer
153+
- name: sort
154+
in: query
155+
description: A date or numeric field to sort by. If order is given but no field specified, created date is used.
156+
schema:
157+
type: string
158+
- name: order
159+
in: query
160+
description: Whether to scored in asc (ascending) or desc (descending) order. If a field is given without an order, asc is used.
161+
schema:
162+
type: string
163+
enum: [asc, desc]
164+
default: asc
153165
responses:
154166
200:
155167
description: OK

0 commit comments

Comments
 (0)