/* * $Id$ * * Copyright 2007 Glencoe Software, Inc. All rights reserved. * Use is subject to license terms supplied in LICENSE.txt */ package ome.services.search; import java.text.DateFormat; import java.text.SimpleDateFormat; import java.util.ArrayList; import java.util.Collections; import java.util.Comparator; import java.util.Date; import java.util.HashMap; import java.util.LinkedList; import java.util.List; import java.util.Map; import ome.conditions.ApiUsageException; import ome.model.IAnnotated; import ome.model.IObject; import ome.model.core.Image; import ome.system.ServiceFactory; import ome.util.search.InvalidQueryException; import ome.util.search.LuceneQueryBuilder; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.apache.lucene.analysis.Analyzer; import org.apache.lucene.queryParser.ParseException; import org.apache.lucene.queryParser.QueryParser; import org.apache.lucene.search.Query; import org.hibernate.Criteria; import org.hibernate.Session; import org.hibernate.criterion.Order; import org.hibernate.criterion.Restrictions; import org.hibernate.criterion.SimpleExpression; import org.hibernate.search.FullTextQuery; import org.hibernate.search.FullTextSession; import org.hibernate.search.ProjectionConstants; import org.hibernate.search.Search; import org.springframework.transaction.annotation.Transactional; import org.springframework.util.Assert; /** * Search based on Lucene's {@link Query} class. Takes a Google-like search * string and returns fully formed objects via Hibernate Search. * * @author Josh Moore, josh at glencoesoftware.com * @since 3.0-Beta3 */ public class FullText extends SearchAction { public final static String ALL_PROJECTIONS = "__ALL_PROJECTIONS"; public final static String TOTAL_SIZE = "TOTAL_SIZE"; private static final DateFormat DATEFORMAT = new SimpleDateFormat( "yyyyMMdd"); private static final Logger log = LoggerFactory.getLogger(FullText.class); private static final long serialVersionUID = 1L; private final String queryStr; private final org.apache.lucene.search.Query q; private final Class<? extends Analyzer> analyzer; /** * Constructs a new instance; Builds a Lucence query with the provided * arguments and passes it on the Lucene parser * * @param values * @param fields * Comma separated field names (name, description, etc.) * @param from * Date range from in form YYYYMMDD * @param to * Date range to in form YYYYMMDD * @param dateType * Type of date {@link ome.api.Search#DATE_TYPE_ACQUISITION} or * {@link ome.api.Search#DATE_TYPE_IMPORT} * @param query * The terms to search for * @param analyzer */ public FullText(SearchValues values, String fields, String from, String to, String dateType, String query, Class<? extends Analyzer> analyzer) { super(values); Assert.notNull(analyzer, "Analyzer required"); this.analyzer = analyzer; if (values.onlyTypes == null || values.onlyTypes.size() != 1) { throw new ApiUsageException( "Searches by full text are currently limited to a single type.\n" + "Plese use Search.onlyType()"); } if ( (query == null || query.length() < 1) && (from == null || from.length() < 1) && (to == null || to.length() < 1)) { throw new IllegalArgumentException("Query string must be non-empty if no date range is provided"); } if ((query.startsWith("*") || query.startsWith("?")) && !values.leadingWildcard) { throw new ApiUsageException("Searches starting with a leading " + "wildcard (*,?) can be slow.\nPlease use " + "setAllowLeadingWildcard() to permit this usage."); } if (query.equals("*")) { throw new ApiUsageException( "Wildcard searches (*) must contain more than a single wildcard. "); } List<String> fieldsArray = new ArrayList<String>(); String[] tmp = fields.split("\\,"); for(String t : tmp) { t = t.trim(); if(t.length()>0) fieldsArray.add(t); } Date dFrom; Date dTo; try { dFrom = (from!=null && from.trim().length()>0) ? DATEFORMAT.parse(from) : null; dTo = (to!=null && to.trim().length()>0) ? DATEFORMAT.parse(to) : null; } catch (java.text.ParseException e1) { throw new ApiUsageException( "Invalid date format, dates must be in format YYYYMMDD."); } if (LuceneQueryBuilder.DATE_ACQUISITION.equals(dateType) && !values.onlyTypes.contains(Image.class)) { // Use import for non-images dateType = LuceneQueryBuilder.DATE_IMPORT; } try { this.queryStr = LuceneQueryBuilder.buildLuceneQuery(fieldsArray, dFrom, dTo, dateType, query); if (this.queryStr.isEmpty()) { q = null; log.info("Generated empty Lucene query"); return; // EARLY EXIT! } else { log.info("Generated Lucene query: "+this.queryStr); } } catch (InvalidQueryException e1) { throw new ApiUsageException( "Invalid query: "+e1.getMessage()); } try { final Analyzer a = analyzer.newInstance(); final QueryParser parser = new /*Analyzing*/QueryParser("combined_fields", a); parser.setAllowLeadingWildcard(values.leadingWildcard); q = parser.parse(queryStr); } catch (ParseException pe) { final String msg = queryStr + " caused a parse exception: " + pe.getMessage(); // No longer logging these, since it's a simple user error ApiUsageException aue = new ApiUsageException(msg); throw aue; } catch (InstantiationException e) { ApiUsageException aue = new ApiUsageException(analyzer.getName() + " cannot be instantiated."); throw aue; } catch (IllegalAccessException e) { ApiUsageException aue = new ApiUsageException(analyzer.getName() + " cannot be instantiated."); throw aue; } } /** * Creates a new instance; Passes the query directly on to the Lucene * parser. * * @param values * @param query * @param analyzer */ public FullText(SearchValues values, String query, Class<? extends Analyzer> analyzer) { super(values); Assert.notNull(analyzer, "Analyzer required"); this.analyzer = analyzer; if (values.onlyTypes == null || values.onlyTypes.size() != 1) { throw new ApiUsageException( "Searches by full text are currently limited to a single type.\n" + "Plese use Search.onlyType()"); } if (query == null || query.length() < 1) { throw new IllegalArgumentException("Query string must be non-empty"); } if ((query.startsWith("*") || query.startsWith("?")) && !values.leadingWildcard) { throw new ApiUsageException("Searches starting with a leading " + "wildcard (*,?) can be slow.\nPlease use " + "setAllowLeadingWildcard() to permit this usage."); } if (query.equals("*")) { throw new ApiUsageException( "Wildcard searches (*) must contain more than a single wildcard. "); } this.queryStr = query; try { final Analyzer a = analyzer.newInstance(); final QueryParser parser = new /*Analyzing*/QueryParser("combined_fields", a); parser.setAllowLeadingWildcard(values.leadingWildcard); q = parser.parse(queryStr); } catch (ParseException pe) { final String msg = queryStr + " caused a parse exception: " + pe.getMessage(); // No longer logging these, since it's a simple user error ApiUsageException aue = new ApiUsageException(msg); throw aue; } catch (InstantiationException e) { ApiUsageException aue = new ApiUsageException(analyzer.getName() + " cannot be instantiated."); throw aue; } catch (IllegalAccessException e) { ApiUsageException aue = new ApiUsageException(analyzer.getName() + " cannot be instantiated."); throw aue; } } private Criteria criteria(FullTextSession session) { final Class<?> cls = values.onlyTypes.get(0); Criteria criteria = session.createCriteria(cls); AnnotationCriteria ann = new AnnotationCriteria(criteria, values.fetchAnnotations); ids(criteria); ownerOrGroup(cls, criteria); createdOrModified(cls, criteria); annotatedBy(ann); annotatedBetween(ann); // annotatedWith if (values.onlyAnnotatedWith != null) { if (values.onlyAnnotatedWith.size() > 1) { throw new ApiUsageException( "HHH-879: " + "At the moment Hibernate cannot fulfill this request.\n" + "Please use only a single onlyAnnotatedWith " + "parameter when performing full text searches."); } else if (values.onlyAnnotatedWith.size() > 0) { if (!IAnnotated.class.isAssignableFrom(cls)) { // A non-IAnnotated object cannot have any // Annotations, and so our results are null return null; // EARLY EXIT ! } else { for (Class<?> annCls : values.onlyAnnotatedWith) { SimpleExpression ofType = new TypeEqualityExpression( "class", annCls); ann.getChild().add(ofType); } } } else { criteria.add(Restrictions.isEmpty("annotationLinks")); } } // orderBy if (values.orderBy.size() > 0) { for (int i = 0; i < values.orderBy.size(); i++) { String orderBy = values.orderBy.get(i); String orderWithoutMode = orderByPath(orderBy); boolean ascending = orderByAscending(orderBy); if (ascending) { criteria.addOrder(Order.asc(orderWithoutMode)); } else { criteria.addOrder(Order.desc(orderWithoutMode)); } } } return criteria; } /** * Allows settings offset and limit on the query. The default implementation * calls setProjection with SCORE and ID, which MUST BE the first two * projection values. Any overriding method may add further projections but * must start with these two. * * @param ftQuery */ protected void initializeQuery(FullTextQuery ftQuery) { ftQuery .setProjection(ProjectionConstants.SCORE, ProjectionConstants.ID); } @Transactional(readOnly = true) public Object doWork(Session s, ServiceFactory sf) { if (q == null) { return null; } final Class<?> cls = values.onlyTypes.get(0); FullTextSession session = Search.createFullTextSession(s); Criteria criteria = criteria(session); if (criteria == null) { return null; // EARLY EXIT. See criteria method. } final String ticket975 = "ticket:975 - Wrong return type: %s instead of %s\n" + "Under some circumstances, byFullText and related methods \n" + "like bySomeMustNone can return instances of the wrong \n" + "types. One known case is the use of onlyAnnotatedWith(). \n" + "If you are recieving this error, please try using the \n" + "intersection/union methods to achieve the same results."; // Main query FullTextQuery ftQuery = session.createFullTextQuery(this.q, cls); initializeQuery(ftQuery); List<?> result = ftQuery.list(); int totalSize = ftQuery.getResultSize(); if (result.size() == 0) { // EARLY EXIT return result; // of wrong type but with generics it doesn't matter } final Map<Long, Integer> order = new HashMap<Long, Integer>(); final Map<Long, Float> scores = new HashMap<Long, Float>(); final Map<Long, Object[]> projections = new HashMap<Long, Object[]>(); for (int i = 0; i < result.size(); i++) { Object[] parts = (Object[]) result.get(i); scores.put((Long) parts[1], (Float) parts[0]); order.put((Long) parts[1], i); projections.put((Long) parts[1], parts); } // TODO Could add a performance optimization here on returnUnloaded final LinkedList<Long> ids = new LinkedList<Long>(scores.keySet()); final List<IObject> check975 = new ArrayList<IObject>(); while (ids.size() > 0) { final List<Long> page = new ArrayList<Long>(); for (int i = 0; i < 1000 && ids.size() > 0; i++) { page.add(ids.removeFirst()); } if (criteria == null) { criteria = criteria(session); } criteria.add(Restrictions.in("id", page)); check975.addAll(criteria.list()); criteria = null; } for (IObject object : check975) { // TODO This is now all but impossible. Remove if (!cls.isAssignableFrom(object.getClass())) { throw new ApiUsageException(String.format(ticket975, object .getClass(), cls)); } else { object.putAt(TOTAL_SIZE, totalSize); object.putAt(ProjectionConstants.SCORE, scores.get(object .getId())); object.putAt(ALL_PROJECTIONS, projections.get(object.getId())); } } // Order return value based on the original ordering final Comparator cmp = new Comparator() { public int compare(Object obj1, Object obj2) { IObject o1 = (IObject) obj1; IObject o2 = (IObject) obj2; Long id1 = o1.getId(); Long id2 = o2.getId(); Integer idx1 = order.get(id1); Integer idx2 = order.get(id2); return idx1.compareTo(idx2); } }; Collections.sort(check975, cmp); return check975; } public Float getScore(IObject object) { Object o = object.retrieve(ProjectionConstants.SCORE); if (o instanceof Float) { return (Float) o; } return null; } public Integer getTotalSize(IObject object) { Object o = object.retrieve(TOTAL_SIZE); if (o instanceof Integer) { return (Integer) o; } return null; } public Object[] getProjections(IObject object) { Object o = object.retrieve(ALL_PROJECTIONS); if (o instanceof Object[]) { return (Object[]) o; } return null; } }