Skip to content

Commit

Permalink
MSEARCH-14 Implement CQL query parser (folio-org#4)
Browse files Browse the repository at this point in the history
* MSEARCH-14 Implement CQL query parser
  • Loading branch information
pfilippov-epam authored Jan 15, 2021
1 parent 3c157b4 commit 207a75d
Show file tree
Hide file tree
Showing 25 changed files with 669 additions and 130 deletions.
1 change: 1 addition & 0 deletions .editorconfig
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ root = true
charset = utf-8
end_of_line = lf
indent_size = 2
ij_continuation_indent_size = 2
indent_style = space
max_line_length = 120
insert_final_newline = true
Expand Down
1 change: 0 additions & 1 deletion Jenkinsfile
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,3 @@ buildMvn {
}
}
}

2 changes: 1 addition & 1 deletion checkstyle/checkstyle.xml
Original file line number Diff line number Diff line change
Expand Up @@ -248,7 +248,7 @@
<property name="basicOffset" value="2"/>
<property name="braceAdjustment" value="2"/>
<property name="caseIndent" value="2"/>
<property name="throwsIndent" value="4"/>
<property name="throwsIndent" value="2"/>
<property name="lineWrappingIndentation" value="2"/>
<property name="arrayInitIndent" value="2"/>
</module>
Expand Down
16 changes: 16 additions & 0 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,22 @@
<version>2.4.0</version>
</dependency>

<dependency>
<groupId>org.folio</groupId>
<artifactId>cql2pgjson</artifactId>
<version>32.1.0</version>
<exclusions>
<exclusion>
<groupId>io.vertx</groupId>
<artifactId>vertx-core</artifactId>
</exclusion>
<exclusion>
<groupId>io.vertx</groupId>
<artifactId>vertx-web</artifactId>
</exclusion>
</exclusions>
</dependency>

<!-- Test dependencies -->
<dependency>
<groupId>org.springframework.boot</groupId>
Expand Down
22 changes: 14 additions & 8 deletions src/main/java/org/folio/search/controller/SearchController.java
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
package org.folio.search.controller;

import static org.folio.search.utils.SearchUtils.INSTANCE_RESOURCE;
import static org.folio.search.utils.SearchUtils.TENANT_HEADER;

import lombok.RequiredArgsConstructor;
import org.folio.search.model.rest.request.SearchRequestBody;
import org.folio.search.model.rest.response.SearchResult;
import org.folio.search.model.service.CqlSearchRequest;
import org.folio.search.service.SearchService;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
Expand All @@ -31,11 +32,16 @@ public class SearchController {
* @param tenantId tenant id from request header
* @return search result as {@link SearchResult} object
*/
@SuppressWarnings("unused")
@PostMapping("/query")
public SearchResult search(
@RequestBody SearchRequestBody requestBody,
@RequestHeader(TENANT_HEADER) String tenantId) {
return searchService.search(requestBody.getQuery(), tenantId);
@GetMapping("/instances")
public SearchResult searchInstances(
SearchRequestBody requestBody,
@RequestHeader(TENANT_HEADER) String tenantId) {
return searchService.search(CqlSearchRequest.builder()
.cqlQuery(requestBody.getQuery())
.resource(INSTANCE_RESOURCE)
.limit(requestBody.getLimit())
.offset(requestBody.getOffset())
.tenantId(tenantId)
.build());
}
}
146 changes: 146 additions & 0 deletions src/main/java/org/folio/search/cql/CqlSearchQueryConverter.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
package org.folio.search.cql;

import static org.elasticsearch.index.query.QueryBuilders.boolQuery;
import static org.elasticsearch.index.query.QueryBuilders.matchQuery;
import static org.elasticsearch.index.query.QueryBuilders.rangeQuery;
import static org.elasticsearch.index.query.QueryBuilders.termQuery;
import static org.elasticsearch.index.query.QueryBuilders.wildcardQuery;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.elasticsearch.index.query.BoolQueryBuilder;
import org.elasticsearch.index.query.QueryBuilder;
import org.elasticsearch.search.builder.SearchSourceBuilder;
import org.elasticsearch.search.sort.SortOrder;
import org.folio.cql2pgjson.exception.CQLFeatureUnsupportedException;
import org.folio.cql2pgjson.model.CqlModifiers;
import org.folio.cql2pgjson.model.CqlSort;
import org.folio.search.exception.SearchServiceException;
import org.folio.search.model.service.CqlSearchRequest;
import org.springframework.stereotype.Component;
import org.z3950.zing.cql.CQLBooleanNode;
import org.z3950.zing.cql.CQLNode;
import org.z3950.zing.cql.CQLParser;
import org.z3950.zing.cql.CQLSortNode;
import org.z3950.zing.cql.CQLTermNode;

/**
* Convert a CQL query into a elasticsearch query.
*
* <p> Contextual Query Language (CQL) Specification:
* <a href="https://www.loc.gov/standards/sru/cql/spec.html">https://www.loc.gov/standards/sru/cql/spec.html</a>
* </p>
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class CqlSearchQueryConverter {

private static final String ASTERISKS_SIGN = "*";

/**
* Parses {@link CqlSearchRequest} object to the elasticsearch.
*/
public SearchSourceBuilder convert(CqlSearchRequest request) {
var cqlQuery = request.getCqlQuery();
try {
var cqlNode = new CQLParser().parse(cqlQuery);
return toCriteria(request, cqlNode);
} catch (Exception e) {
throw new SearchServiceException(String.format(
"Failed to parse cql query [cql: '%s', resource: %s]", cqlQuery, request.getResource()), e);
}
}

private SearchSourceBuilder toCriteria(CqlSearchRequest searchRequest, CQLNode node)
throws CQLFeatureUnsupportedException {
var queryBuilder = new SearchSourceBuilder();
queryBuilder.trackTotalHits(true);

if (node instanceof CQLSortNode) {
for (var sortIndex : ((CQLSortNode) node).getSortIndexes()) {
var modifiers = new CqlModifiers(sortIndex);
queryBuilder.sort(sortIndex.getBase(), getSortOrder(modifiers.getCqlSort()));
}
}

return queryBuilder
.query(convertToQuery(node))
.from(searchRequest.getOffset())
.size(searchRequest.getLimit());
}

private QueryBuilder convertToQuery(CQLNode node) {
var cqlNode = node;
if (node instanceof CQLSortNode) {
cqlNode = ((CQLSortNode) node).getSubtree();
}
if (cqlNode instanceof CQLTermNode) {
return convertToTermQuery((CQLTermNode) cqlNode);
}
if (cqlNode instanceof CQLBooleanNode) {
return convertToBoolQuery((CQLBooleanNode) cqlNode);
}
throw new UnsupportedOperationException("Unsupported node: " + node.getClass().getSimpleName());
}

private QueryBuilder convertToTermQuery(CQLTermNode node) {
var fieldName = node.getIndex();
var term = node.getTerm();
if (term.contains(ASTERISKS_SIGN)) {
return wildcardQuery(fieldName, term);
}
var comparator = StringUtils.lowerCase(node.getRelation().getBase());
switch (comparator) {
case "=":
return termQuery(fieldName, term);
case "adj":
case "all":
case "any":
case "==":
return matchQuery(fieldName, term);
case "<>":
return boolQuery().mustNot(termQuery(fieldName, term));
case "<":
return rangeQuery(fieldName).lt(term);
case ">":
return rangeQuery(fieldName).gt(term);
case "<=":
return rangeQuery(fieldName).lte(term);
case ">=":
return rangeQuery(fieldName).gte(term);
default:
throw unsupportedException(comparator);
}
}

private BoolQueryBuilder convertToBoolQuery(CQLBooleanNode node) {
var operator = node.getOperator();
var boolQuery = boolQuery();
switch (operator) {
case OR:
return boolQuery
.should(convertToQuery(node.getLeftOperand()))
.should(convertToQuery(node.getRightOperand()));
case AND:
return boolQuery
.must(convertToQuery(node.getLeftOperand()))
.must(convertToQuery(node.getRightOperand()));
case NOT:
return boolQuery
.must(convertToQuery(node.getLeftOperand()))
.mustNot(convertToQuery(node.getRightOperand()));
default:
throw unsupportedException(operator.name());
}
}

private static SortOrder getSortOrder(CqlSort cqlSort) {
return cqlSort == CqlSort.DESCENDING ? SortOrder.DESC : SortOrder.ASC;
}

private static UnsupportedOperationException unsupportedException(String operator) {
return new UnsupportedOperationException(String.format("Not implemented yet [operator: %s]", operator));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import lombok.extern.slf4j.Slf4j;
import org.folio.search.model.ResourceEventBody;
import org.folio.search.service.IndexService;
import org.folio.search.utils.SearchUtils;
import org.springframework.kafka.annotation.KafkaListener;
import org.springframework.stereotype.Component;

Expand All @@ -18,7 +19,6 @@
@RequiredArgsConstructor
public class KafkaMessageListener {

private static final String INSTANCE_RESOURCE = "instance";
private final IndexService indexService;

/**
Expand All @@ -36,7 +36,7 @@ public class KafkaMessageListener {
public void handleEvents(List<ResourceEventBody> events) {
log.info("Processing resource events from kafka [eventsCount: {}]", events.size());
var resources = events.stream()
.map(event -> event.withResourceName(INSTANCE_RESOURCE))
.map(event -> event.withResourceName(SearchUtils.INSTANCE_RESOURCE))
.collect(toList());

indexService.indexResources(resources);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,18 @@
@AllArgsConstructor(staticName = "of")
public class SearchRequestBody {

private String type;
/**
* Search query.
*/
private String query;

/**
* Request page number.
*/
private Integer limit = 100;

/**
* Request page size.
*/
private Integer offset = 0;
}
Original file line number Diff line number Diff line change
@@ -1,18 +1,10 @@
package org.folio.search.model.rest.response;

import java.util.List;
import lombok.Data;
import lombok.NoArgsConstructor;

@Data
@NoArgsConstructor
public class PagedResult<T> {
public interface PagedResult<T> {

private long total;
private long totalPages;
long getTotalRecords();

private int pageSize;
private int pageNumber;

private List<T> content;
List<T> getInstances();
}
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
package org.folio.search.model.rest.response;

import java.util.List;
import java.util.Map;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;

@Data
@NoArgsConstructor
@EqualsAndHashCode(callSuper = true)
public class SearchResult extends PagedResult<Object> {
@AllArgsConstructor(staticName = "of")
public class SearchResult implements PagedResult<Map<String, Object>> {

private final long totalRecords;
private final List<Map<String, Object>> instances;
}
43 changes: 43 additions & 0 deletions src/main/java/org/folio/search/model/service/CqlSearchRequest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package org.folio.search.model.service;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;

/**
* CQL based search request model.
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor(staticName = "of")
public class CqlSearchRequest {

/**
* Resource name.
*/
private String resource;

/**
* CQL query.
*/
private String cqlQuery;

/**
* Request tenant id.
*/
private String tenantId;

/**
* Page size.
*/
@Builder.Default
private Integer limit = 100;

/**
* Page number.
*/
@Builder.Default
private Integer offset = 0;
}
Loading

0 comments on commit 207a75d

Please sign in to comment.