package gov.nysenate.openleg.service.bill.search;
import com.google.common.collect.Range;
import com.google.common.eventbus.EventBus;
import com.google.common.eventbus.Subscribe;
import gov.nysenate.openleg.dao.base.LimitOffset;
import gov.nysenate.openleg.dao.base.SearchIndex;
import gov.nysenate.openleg.dao.bill.search.ElasticBillSearchDao;
import gov.nysenate.openleg.config.Environment;
import gov.nysenate.openleg.model.base.SessionYear;
import gov.nysenate.openleg.model.bill.BaseBillId;
import gov.nysenate.openleg.model.bill.Bill;
import gov.nysenate.openleg.model.bill.BillId;
import gov.nysenate.openleg.model.search.*;
import gov.nysenate.openleg.service.base.search.ElasticSearchServiceUtils;
import gov.nysenate.openleg.service.base.search.IndexedSearchService;
import gov.nysenate.openleg.service.bill.data.BillDataService;
import gov.nysenate.openleg.service.bill.event.BillUpdateEvent;
import gov.nysenate.openleg.service.bill.event.BulkBillUpdateEvent;
import org.elasticsearch.ElasticsearchException;
import org.elasticsearch.index.query.*;
import org.elasticsearch.search.SearchParseException;
import org.elasticsearch.search.rescore.RescoreBuilder;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import javax.annotation.PostConstruct;
import java.time.LocalDate;
import java.util.Collection;
import java.util.List;
import java.util.Optional;
import java.util.regex.Matcher;
import static java.util.stream.Collectors.toList;
@Service
public class ElasticBillSearchService implements BillSearchService, IndexedSearchService<Bill>
{
private static final Logger logger = LoggerFactory.getLogger(ElasticBillSearchService.class);
@Autowired protected Environment env;
@Autowired protected EventBus eventBus;
@Autowired protected ElasticBillSearchDao billSearchDao;
@Autowired protected BillDataService billDataService;
@PostConstruct
protected void init() {
eventBus.register(this);
}
/** --- BillSearchService implementation --- */
/** {@inheritDoc} */
@Override
public SearchResults<BaseBillId> searchBills(SessionYear session, String sort, LimitOffset limOff) throws SearchException {
return searchBills(
QueryBuilders.boolQuery()
.must(QueryBuilders.matchAllQuery())
.filter(QueryBuilders.termQuery("session", session.getYear())),
null, null, sort, limOff);
}
/** {@inheritDoc} */
@Override
public SearchResults<BaseBillId> searchBills(String query, String sort, LimitOffset limOff) throws SearchException {
query = smartSearch(query);
QueryBuilder queryBuilder = QueryBuilders.queryStringQuery(query);
return searchBills(queryBuilder, null, null, sort, limOff);
}
/** {@inheritDoc} */
@Override
public SearchResults<BaseBillId> searchBills(String query, SessionYear session, String sort, LimitOffset limOff) throws SearchException {
query = smartSearch(query);
TermQueryBuilder sessionFilter = QueryBuilders.termQuery("session", session.getYear());
return searchBills(
QueryBuilders.boolQuery()
.must(QueryBuilders.queryStringQuery(query))
.filter(sessionFilter),
null, null, sort, limOff);
}
/**
* Delegates to the underlying bill search dao.
*/
private SearchResults<BaseBillId> searchBills(QueryBuilder query, QueryBuilder postFilter, RescoreBuilder.Rescorer rescorer,
String sort, LimitOffset limOff)
throws SearchException {
if (limOff == null) limOff = LimitOffset.TEN;
try {
return billSearchDao.searchBills(query, postFilter, rescorer,
ElasticSearchServiceUtils.extractSortBuilders(sort), limOff);
}
catch (SearchParseException ex) {
throw new SearchException("Invalid query string", ex);
}
catch (ElasticsearchException ex) {
throw new UnexpectedSearchException(ex);
}
}
private String smartSearch(String query) {
if (query != null && !query.contains(":")) {
Matcher matcher = BillId.billIdPattern.matcher(query.replaceAll("\\s", ""));
if (matcher.find()) {
query = String.format("(printNo:%s OR basePrintNo:%s) AND session:%s",
matcher.group("printNo"), matcher.group("printNo"), matcher.group("year"));
}
}
return query;
}
/** {@inheritDoc} */
@Override
@Subscribe
public void handleBillUpdate(BillUpdateEvent billUpdateEvent) {
if (billUpdateEvent.getBill() != null) {
updateIndex(billUpdateEvent.getBill());
}
}
/** {@inheritDoc} */
@Override
@Subscribe
public void handleBulkBillUpdate(BulkBillUpdateEvent bulkBillUpdateEvent) {
if (bulkBillUpdateEvent.getBills() != null) {
updateIndex(bulkBillUpdateEvent.getBills());
}
}
/** --- IndexedSearchService implementation --- */
/** {@inheritDoc} */
@Override
public void updateIndex(Bill bill) {
if (env.isElasticIndexing()) {
if (isBillIndexable(bill)) {
logger.info("Indexing bill {} into elastic search.", bill.getBaseBillId());
billSearchDao.updateBillIndex(bill);
}
else if (bill != null) {
logger.info("Deleting {} from index.", bill.getBaseBillId());
billSearchDao.deleteBillFromIndex(bill.getBaseBillId());
}
}
}
/** {@inheritDoc} */
@Override
public void updateIndex(Collection<Bill> bills) {
if (env.isElasticIndexing() && !bills.isEmpty()) {
List<Bill> indexableBills = bills.stream()
.filter(b -> isBillIndexable(b))
.collect(toList());
logger.info("Indexing {} valid bills into elastic search.", indexableBills.size());
billSearchDao.updateBillIndex(indexableBills);
// Ensure any bills that currently don't meet the criteria are not in the index.
if (indexableBills.size() != bills.size()) {
bills.stream()
.filter(b -> !isBillIndexable(b) && b != null)
.forEach(b -> {
logger.info("Deleting {} from index.", b.getBaseBillId());
billSearchDao.deleteBillFromIndex(b.getBaseBillId());
});
}
}
}
/** {@inheritDoc} */
@Override
public void clearIndex() {
billSearchDao.purgeIndices();
billSearchDao.createIndices();
}
/** {@inheritDoc} */
@Override
public void rebuildIndex() {
clearIndex();
Optional<Range<SessionYear>> sessions = billDataService.activeSessionRange();
if (sessions.isPresent()) {
SessionYear session = sessions.get().lowerEndpoint();
while (session.getSessionStartYear() <= LocalDate.now().getYear()) {
LimitOffset limOff = LimitOffset.THOUSAND;
List<BaseBillId> billIds = billDataService.getBillIds(session, limOff);
while (!billIds.isEmpty()) {
logger.info("Indexing {} bills starting from {}", billIds.size(), billIds.get(0));
updateIndex(billIds.stream().map(id -> billDataService.getBill(id)).collect(toList()));
limOff = limOff.next();
billIds = billDataService.getBillIds(session, limOff);
}
session = session.next();
}
}
else {
logger.info("Can't rebuild the bill search index because there are no bills. Clearing it instead!");
clearIndex();
}
}
/** {@inheritDoc} */
@Override
@Subscribe
public void handleRebuildEvent(RebuildIndexEvent event) {
if (event.affects(SearchIndex.BILL)) {
logger.info("Handling bill re-index event!");
try {
rebuildIndex();
}
catch (Exception ex) {
logger.error("Unexpected exception during handling of Bill RebuildIndexEvent!", ex);
}
}
}
/** {@inheritDoc} */
@Override
@Subscribe
public void handleClearEvent(ClearIndexEvent event) {
if (event.affects(SearchIndex.BILL)) {
clearIndex();
}
}
/** --- Internal --- */
/**
* Returns true if the given bill meets the criteria for being indexed in the search layer.
*
* @param bill Bill
* @return boolean
*/
protected boolean isBillIndexable(Bill bill) {
return bill != null && bill.isBaseVersionPublished();
}
}