Searching
The Searchable Plugin comes with a simple controller and view which allows you to quickly test string queries: http://localhost:8080/YOUR-APP-NAME-HERE/searchable.
There is also an API provided by the SearchableService and new domain class methods that are added to your searchable domain classes. The available methods are the same but the domain class methods only return hits for instances of that class.
The methods are:
- search - Returns a search result object containing a subset of objects matching the query.
- searchTop - Returns the first result object matching the query.
- searchEvery - Returns all result objects matching the query.
- countHits - Returns the number of hits for a query.
- termFreqs - Returns term frequencies for the terms in the index (advanced).
Here are a few quick examples, a complete reference follows:
// Get a subset of 'active' domain objects fuzzy matching 'Chelsea' def searchResult = searchableService.search("+active:true +Chelsea~", [offset: 20, max: 20]) println "${searchResult.total} hits:" for (i in 0..<searchResult.results.size() { println "${searchResult.offset + i + 1}: ${searchResult.results[i].toString()} (score ${searchResult.scores[i]})" } // Find the lowest priced product matching the query '(laser OR L.A.S.E.R.) beam' def product = Product.searchTop("(laser OR L.A.S.E.R.) beam", [sort: 'price', order: 'asc']) // Get all articles that contain the terms 'police' OR 'doughnut', // giving a higher score to items matching 'doughnut', and load from DB def articles = Article.searchEvery("Police doughnut^2.0", [reload: true]) // Count the number of domain objects matching 'cow pie' def count = searchableService.countHits("cow pie")
[ Request for feedback: what do you think of the method names? Maybe search, everyHit, topHit/firstHit and countHits might be better? ]
The query may be either a query string in Lucene's Query Syntax or a Closure which uses the query builder.
Each method has additional options, described in the reference section below, and you can also apply a custom sort order easily.
Searching method reference
Remember that these methods are available with the SearchableService and as domain class methods. The methods are the same but the domain class methods only return hits for instances of that class.
search
Description
Returns a search result object containing a subset of objects matching the query. The order of the hits is either by relevance (the default) or custom sort defined by you.
The query can be either a String or query-building Closure and the options Map is optional:
def searchResult = xxx.search(queryString)
def searchResult = xxx.search(queryString, options)
def searchResult = xxx.search {
// ...
}
def searchResult = xxx.search(options) {
// ...
}
Parameters
- queryString - The query String. (See String Queries)
- options - A Map of options (may be omitted)
- builderClosure - A query-buiding Closure. (See Programmatic Queries - Query Builder)
The options Map may define:
| offset | The 0-based start result offset (default 0) |
| max | The maximum number of results to return (default 10) |
| sort | The field to sort results by (default is 'SCORE'). (See Sorting) |
| order or direction | The sort order, only used with 'sort' (default is 'auto'). (See Sorting) |
| escape | Whether to escape the query String (only applies to String queries) |
| reload | If true, reloads the objects from the database, attaching them to a Hibernate session, otherwise the objects are reconstructed from the index (default false) |
| withHighlighter | A groovy.lang.Closure instance that is called for each search result hit to support highlighting. See highlighting for more |
There are also a few advanced options just for String queries: see Advanced String Query Options.
Returns
An object with the following properties
- results - A List of matching domain objects
- scores - A List of scores: one for for each entry in results (since 0.4)
- total - The total number of hits
- offset - The 0-based hit object offset
- max - The maximum number of results requested. Note that this may be higher than results.size() if there were fewer hits than requested
Examples
// query string, no options def searchResult = Book.search("white corpuscles") // query string, with options def searchResult = searchableService.search("Swathe of industries", [reload: true, offset: 10]) // query builder closure, no options def searchResult = searchableService.search { fuzzy('name', 'lundon') } // query builder closure, with options def searchResult = Book.search(offset: 25, max: 30) { gt('averageReview', 3) queryString('learning techniques') }
searchTop
Description
Returns the first domain object matching the query. The first result is the most relevant (the default) or the first for a custom sort defined by you.
The query can be either a String or query-building Closure and the options Map is optional:
def object = xxx.searchTop(queryString)
def object = xxx.searchTop(queryString, options)
def object = xxx.searchTop {
// ...
}
def object = xxx.searchTop(options) {
// ...
}
Parameters
- queryString - The query String. (See String Queries)
- options - A Map of options (may be omitted)
- builderClosure - A query-buiding Closure. (See Programmatic Queries - Query Builder)
The options Map may define:
| sort | The field to sort results by (default is 'SCORE'). (See Sorting) |
| order or direction | The sort order, only used with 'sort' (default is 'auto'). (See Sorting) |
| escape | Whether to escape the query String (only applies to String queries) |
| reload | If true, reloads the object from the database, attaching it to a Hibernate session, otherwise the object is reconstructed from the index (default false) |
There are also a few advanced options just for String queries: see Advanced String Query Options.
Returns
A single domain class instance, or null if nothing matched the query
Examples
// query string, no options def album = Music.searchTop("Silent Alarm") // query string, with options def topHit = searchableService.searchTop("seed planting times", [reload: true, offset: 10]) // query builder closure, no options def topHit = searchableService.searchTop { multiPhrase('content') { add('tick') add('followed') add('tock') } } // query builder closure, with options def album = Music.searchTop(reload: true, sort: 'reviewScore', order: 'reverse') { term('artist', 'bloc party') term('format', 'ALBUM') }
searchEvery
Description
Returns a List of domain objects matching the query. The result are ordered by relevance (the default) or a custom sort defined by you.
The query can be either a String or query-building Closure and the options Map is optional:
def list = xxx.searchEvery(queryString)
def list = xxx.searchEvery(queryString, options)
def list = xxx.searchEvery {
// ...
}
def list = xxx.searchEvery(options) {
// ...
}
Parameters
- queryString - The query String. (See String Queries)
- options - A Map of options (may be omitted)
- builderClosure - A query-buiding Closure. (See Programmatic Queries - Query Builder)
The options Map may define:
| sort | The field to sort results by (default is 'SCORE'). (See Sorting) |
| order or direction | The sort order, only used with 'sort' (default is 'auto'). (See Sorting) |
| escape | Whether to escape the query String (only applies to String queries) |
| reload | If true, reloads the objects from the database, attaching them to a Hibernate session, otherwise the objectss are reconstructed from the index (default false) |
| withHighlighter | A groovy.lang.Closure instance that is called for each search result hit to support highlighting. See highlighting for more |
There are also a few advanced options just for String queries: see Advanced String Query Options.
Returns
A all domain class instances matching the query.
Examples
// query string, no options def artists = Artist.searchEvery("Ugly Duckling") // query string, with options def hits = searchableService.searchEvery("Music of the Spheres", [sort: 'artistName', andDefaultOperator: true]) // query builder closure, no options def hits = searchableService.searchEvery { multiPhrase('title') { add('Northen') add('Soul') } } // query builder closure, with options def artists = Artist.searchEvery(sort: 'stars', order: 'desc') { queryString('Posse Cut', defaultSearchProperty: 'songs') term('deleted', false) }
countHits
Description
Returns the number of hits for a query.
The query can be either a String or query-building Closure and the options Map is optional:
def every = xxx.countHits(queryString)
def every = xxx.countHits(queryString, options)
def every = xxx.countHits {
// ...
}
def top = xxx.countHits(options) {
// ...
}
Parameters
- queryString - The query String. (See String Queries)
- options - A Map of options (may be omitted)
- builderClosure - A query-buiding Closure. (See Programmatic Queries - Query Builder)
The options Map may define:
! escape | Whether to escape the query String (only applies to String queries) |
There are also a few advanced options just for String queries: see Advanced String Query Options.
Returns
The number of hits for the query
Examples
// query string, with options def count = Show.countHits("CSI [Las Vegas]", escape: true) // query string, with options def count = searchableService.countHits("CSI [Miami]", [escape: true]) // query builder closure, no options def count = searchableService.countHits { term('format', 'MP3') multiPhrase('title') { add('Wrecking') add('Ball') } } // query builder closure def count = Show.countHits { term('keywords', 'crime') term('keywords', 'drama') queryString('ongoing love interest subplot', [defaultSearchProperty: 'notes']) }
termFreqs
Description
Returns term frequencies for the terms in the search index.
| What's a term frequency? Normally you don't really need to care about term frequencies, but they can be a useful for search index analysis or creating tag-cloud style models. A term frequency in its most basic form represents two things: a term and a frequency: the term is a term (normally a word) known to the search index, and the frequency is the number of times it appears in the index. Armed with a whole bunch of term frequencies you can start to get a picture of what are the more common terms in the index and what are the less common terms. Term frequencies are useful for a number of things, eg, they can be used to implement spelling, and more like this style suggestions. |
When fetching term frequencies, you can choose which classes and class properties you want to restrict the term search to (if you like) and can limit the size of the result set as well as normalise the frequency numbers. (That's the stuff that makes tag-clouds easy.)
As with the other search methods, calling termFreqs on a class limits the results to instances of that class; calling it on the searchableService returns results for the whole index.
The termFreqs method has a few different parameter styles:
def termFreqs = xxx.termFreqs() // returns term frequencies for all searchable properties def termFreqs = xxx.termFreqs(propertyName) // returns term frequencies for the named domain class property def termFreqs = xxx.termFreqs(propertyName, options) // returns term frequencies for the named property with additional options def termFreqs = xxx.termFreqs(options) // returns term frequencies with all parameters defined by the options Map
Parameters
- propertyName - The domain class property name (may be omitted)
- options - A Map of options (may be omitted)
The options Map may define:
| properties | A List of property names; use this to get term freqs for multiple properties (optional) |
| size | The maximum number of term freqs to return (optional, default is all) |
| normalise or normalize | A Groovy Range used to normalise the frequencies; without this option the frequencies of the returned term freqs are the actual number of occurences for the term in the index |
| class | The class to restrict the term freqs to (optional) |
| sort | Sorts the term frequencies; either "term" to sort alphabetically by term or "freq" to sort by highest frequency first (optional, default is "freq") |
Returns
An array of CompassTermFreq objects, each with the following methods:
- getTerm - returns the term
- getFreq - returns the frequency
- getProperty - returns the searchable property from which the term comes
Examples
// print all Book term frequencies def termFreqs = Book.termFreqs() termFreqs.each { println "${it.term} occurs ${it.freq} times in the index for Book instances" } // get Book term frequencies for Book#title def termFreqs = Book.termFreqs("title") // get Book term frequencies for Book#title, limiting the size to 100 // and normalising (UK spelling) the frequencies between 0 (minimum) and 1 (maximum) def termFreqs = Book.termFreqs("title", size: 100, normalise: 0..1) // get terms from all properties in the index, sorting by term and limited to the Author class def termFreqs = searchableService.search(class: Author, sort: 'term') // get terms from searchable "title" and "description" properties in the index, // limiting and normalsing (US spelling this time) def termFreqs = searchableService.search(properties: ['title', 'description'], size: 1000, normalize: 0..1)
String Queries
A string query can be as simple as
- "hello world"
(meaning matches should have "hello" or "world" in their searchable content)
or more complicated like
- "+type:fruit +(vitamins:c vitamins:b1) -color:green calories:[150 TO *]"
(meaning matches must have a 'fruit' value for 'type', must have either 'c' or 'b1' values for 'vitamins', must not have 'green' as a value for 'color' and should have a value of at least 150 for 'calories').
See Lucene's string query syntax for more examples.
String queries are by most likely entered by your users in a web form. And because users may enter special characters, which can cause query parse exceptions and other problems you can always provide the escape option to make the query safe:
Product.search("wireless projector *", escape: true) // without "escape: true" would throw ParseException due to trailing " *"
By default escape is false but you can change the default to true: see Configuration.
Advanced String Query Options
The search methods additionaly accept the following options when using String queries:
| Option name | Description | Default value | Example |
|---|---|---|---|
| escape | If true escapes special query characters | false | search("[this is a bad query]", escape: true) // ==> same as "\[this is a bad query\]" |
| defaultProperty or defaultSearchProperty | The searchable property for un-prefixed terms. Cannot be used with the properties option. | "all" | search("tomato soup tags:recipie", defaultProperty: 'name') // ==> same as "name:tomato name:soup tags:recipie" |
| properties since 0.4 |
The names of the class properties in which to search. Cannot be used with the defaultProperty/defaultSearchProperty option. | none | search("Hawaii Five-O", properties: ['title', 'desc']) // ==> same as '(desc:hawaii titles:hawaii) (desc:"five o" titles:"five o")' |
| andDefaultOperator or useAndDefaultOperator | When true uses AND instead of OR as the default query operator. Only useful for multi-word queries | false | search("mango chutney", useAndDefaultOperator: true) // ==> same as "mango AND chutney" and "+mango +chutney" |
| analyzer | The name of a query analyzer. With Compass settings, you can define a new default with the name search and/or additional analyzers with new names. |
"search" | search("cowboy john", analyzer: 'myFunkyAnalyzer') // ==> uses the analyzer configured for name "myFunkyAnalyzer" |
| parser or queryParser | The name of a query parser. With Compass settings, you can define a new default with the name default and/or additional parsers with new names. |
"default" | search("european bob", parser: 'myFunkyParser') // ==> uses the query parser configured for name "myFunkyParser" |
Sorting
Search results are sorted by relevance by default, the most relevant being the first.
You can specify a custom sort when using either String queries or query builder Closure using the sort and order/direction options:
- sort - Either 'SCORE' (ie, relevance) or the name of a mapped class property
- order or direction - One of 'auto', 'reverse', 'asc', desc'.
// Get Property instances matching "Riverside Apartment" sorted by price in ascending order def searchResult = Property.search("Riverside Apartment", [sort: 'price', order: 'asc']) // Find the least relevant match def leastRelevant = searchableService.searchTop(sort: 'SCORE', direction: 'reverse') { fuzzy('word', "roam") }
The values 'auto' and 'reverse' are symbols for Compass API constants: when sorting by SCORE an order of 'auto' means highest scoring first, 'reverse' means lowest scoring first. When sorting by anything else, 'auto' is the natural ascending order and 'reverse' is the natural descending order.
Searchable Plugin also gives you the opton to use 'asc' or 'desc' for the order or direction value.
Why are there two options for sort order - order and direction - and are they different? No, they both control the sort order, and you can mix the option names and values. The reason we have both is to satisfy those people more familiar with the GORM style parameters (order + asc/desc) and those more familiar with Compass and search engine queries (direction + auto/reverse). Choose whichever you prefer.
The following table summaries the behavoir of these option combinations:
| sort | order or direction | Sorting behavoir |
|---|---|---|
| SCORE | auto | The results are ordered most-relevant (highest scoring) first. This is the default, so you don't need to ever provide this combination |
| SCORE | reverse | The results are ordered least-relevant (lowest scoring) first. |
| SCORE | asc | The results are ordered least-relevant (lowest scoring) first. |
| SCORE | desc | The results are ordered most-relevant (highest scoring) first. This is the same as SCORE + auto, which is the default, so you don't need to ever provide this combination |
| someProperty | asc | The results are order in natural order for the 'someProperty' field value, eg, String ascending, Number ascending |
| someProperty | desc | The results are order in revserse natural order for the 'someProperty' field value, eg, String descending, Number descending |
| someProperty | auto | The results are order in natural order for the 'someProperty' field value, eg, String ascending, Number ascending |
| someProperty | reverse | The results are order in revserse natural order for the 'someProperty' field value, eg, String descending, Number descending |
It is also possible to add sorting using a query builder Closure (See Programmatic Queries - Query Builder), in fact with that technique you can add multiple sorts, eg:
import org.compass.core.* // ... // Sort first by score (relevance), then by most votes (when the score is equal) def hits = searchableService.searchEvery { queryString('reality tv') sort(CompassQuery.SortImplicitType.SCORE) sort('votes', CompassQuery.SortDirection.REVERSE) }
When using a query building Closure you can also combine sorts in the closure with the above sort/order options. If you do this, the sort/order options are added as the last sort in the chain and therefore are applied last.
Programmatic Queries - Query Builder
Searchable Plugin comes with a Groovy builder for Compass queries, making the job of programatically building queries easy.
When you pass a closure to one of the search/searchTop/searchEvery/countHits methods, you are using the query builder.
The query builder syntax mirrors Compass's own CompassQueryBuilder API: to get the most out of the Searchable Plugin's query builder, you should take a moment to familiarise yourself with the CompassQueryBuilder.
However the Groovy query builder improves on the raw CompassQueryBuilder experience in a few ways. First it relieves you of the burden to call toQuery() when using the various specific builders obtained from calling some CompassQueryBuilder methods, and it allows you to easily nest these specific builders using closures.
It also simplifies the job of constructing boolean queries by not requiring you to explcitly create a boolean builder and add "should" clauses. You still need to explicitly add must and must not clauses, but other clauses in a boolean context are assumed to be should clauses.
The methods you can invoke depend on the current context. In the outer-most context you can invoke CompassQueryBuilder methods. Within nested contexts (which are created by a closure) you can call both CompassQueryBuilder methods and whatever methods the current nested builder supports.
The builder also allows you to call Compass's various query builders' options "setters" using a literal options Map as the last argument, instead of requiring method invocations.
The builder shortens a few method names too, so the CompassBooleanQueryBuilder addMust/addShould/addMustNot methods can be shortened to must/should/mustNot (and as already mentioned you typically don't need to use should because any clause that is not a must or mustNot is assumed to be should) and the CompassQuery addSort method can be shortedned to sort.
And finally because it's Groovy, you have the language at your disposal so you can use control flow, loops, variables etc.
Enough theory, let's explore some examples.
Say you want to search for items in the index where 'pages' is less than 50 and 'type' is 'poetry'. A String query for this might look like "pages:[* TO 50] type:poetry".
Using the builder you would do
search { // <-- create an implicit boolean query
lt('pages', 50) // <-- uses CompassQueryBuilder#lt, and adds a boolean "should" clause
term('type', 'poetry') // <-- uses CompassQueryBuilder#term, and adds a boolean "should" clause
}
We just built a boolean query! It has two clauses: the search must match EITHER 'pages' < 50 OR 'type' == 'poetry'. Not bad but this query will match when either condition is true, and not necessarily both.
So let's improve the search results and make sure that matches DO have 'type' == 'poetry'.
search { // <-- creates an implicit boolean query
lt('pages', 50) // <-- uses CompassQueryBuilder#lt, and adds a boolean "should"
must(term('type', 'poetry')) // <-- uses CompassQueryBuilder#term, adds a boolean "must"
}
Ok let's assume we're getting matches we don't want, and so we add another "mustNot" clause:
search { // <-- creates an implicit boolean query
lt('pages', 50) // <-- uses CompassQueryBuilder#lt, and adds a boolean "should"
must(term('type', 'poetry')) // <-- uses CompassQueryBuilder#term, adds a boolean "must"
mustNot(term('theme', 'war')) // <-- uses CompassQueryBuilder#term, adds a boolean "mustNot"
}
Great, so now we're matching any non-war poetry under 50 pages!
Now we want to search for a specific phrase "all hands on deck", let's add it to the query. First we try it as a nested String query:
search { // <-- creates an implicit boolean query
must(queryString("all hands on deck")) // <-- uses CompassQueryBuilder#queryString, and adds a boolean must
lt('pages', 50) // <-- uses CompassQueryBuilder#lt, and adds a boolean "should"
must(term('type', 'poetry')) // <-- uses CompassQueryBuilder#term, adds a boolean "must"
mustNot(term('theme', 'war')) // <-- uses CompassQueryBuilder#term, adds a boolean "mustNot"
}
The nested String query has introduced the possibility for the query to matching anything containing any of the words "all", "hands", "on", or "deck" and maybe in any searchable property. But we wanted to search for this exact text, hmmm.
Check the CompassQueryBuilder API and you'll notice that CompassQueryBuilder#queryString returns a CompassQueryStringBuilder, so in fact we can create a context for that builder with a closure and in that closure call any methods the CompassQueryStringBuilder exposes to tighten up the query:
search { // <-- creates an implicit boolean query
must(queryString("all hands on deck") { // <-- creates a nested CompassQueryStringBuilder context
useAndDefaultOperator() // <-- calls CompassQueryStringBuilder#useAndDefaultOperator
setDefaultSearchProperty('body') // <-- calls CompassQueryStringBuilder#setDefaultSearchProperty
}) // <-- added as boolean must to surrounding boolean
lt('pages', 50) // <-- uses CompassQueryBuilder#lt, and adds a boolean "should"
must(term('type', 'poetry')) // <-- uses CompassQueryBuilder#term, adds a boolean "must"
mustNot(term('theme', 'war')) // <-- uses CompassQueryBuilder#term, adds a boolean "mustNot"
}
Ok, now the query requires that ALL of the words "all hands on deck" are present in matches. And we have set the defaultSearchProperty to 'body', so the string query will now match terms in the searchable 'body' property.
But we can do a little better. First let's use an options Map instead of calling those setters:
search { // <-- creates an implicit boolean query
must(queryString("all hands on deck", [useAndDefaultOperator: true, defaultSearchProperty: 'body']))
// ^^ add a "must" nested query string, calling useAndDefaultOperator() and setDefaultSearchProperty('body')
// on the CompassQueryStringBuilder
lt('pages', 50) // <-- uses CompassQueryBuilder#lt, and adds a boolean "should"
must(term('type', 'poetry')) // <-- uses CompassQueryBuilder#term, adds a boolean "must"
mustNot(term('theme', 'war')) // <-- uses CompassQueryBuilder#term, adds a boolean "mustNot"
}
This is the same query as before, only fewer lines of code.
So now let's use CompassQueryBuilder#multiPhrase instead of queryString, since a multi-phrase query can require that the words appear in order, whereas a query string generally just requires the words appear somewhere.
search { // <-- creates an implicit boolean query
must(multiPhrase("body", [slop: 2]) { // <-- creates a nested CompassMultiPhraseQueryBuilder context, calling setSlop(2)
add('all') // <-- calls CompassMultiPhraseQueryBuilder#add
add('hands') // <-- calls CompassMultiPhraseQueryBuilder#add
add('on')
add('deck')
}) // <-- adds multiPhrase as boolean "must"
lt('pages', 50) // <-- uses CompassQueryBuilder#lt, and adds a boolean "should"
must(term('type', 'poetry')) // <-- uses CompassQueryBuilder#term, adds a boolean "must"
mustNot(term('theme', 'war')) // <-- uses CompassQueryBuilder#term, adds a boolean "mustNot"
}
Let's go all out for the final example and add three new features:
move the number pages clause into a nested boolean with a new clause for items with 50 or more pages, and a "boost" (meaning higher score/relevance) for the smaller number, (ii) a clause for 'publishedDate' being within the last 4 weeks (and note the use of a Date object) and (iii) a sort first by relevance then author surname.
search { // <-- creates an implicit boolean query
must(multiPhrase("body", [slop: 2]) { // <-- creates a nested CompassMultiPhraseQueryBuilder context, and calls setSlop(2)
add('all') // <-- calls CompassMultiPhraseQueryBuilder#add
add('hands') // <-- calls CompassMultiPhraseQueryBuilder#add
add('on')
add('deck')
}) // <-- adds multiPhrase as boolean "must"
must { // <-- creates an nested boolean query, implicitly
ge('pages', 50) // <-- uses CompassQueryBuilder#ge, adds a boolean "should"
lt('pages', 50, [boost: 1.5]) // <-- uses CompassQueryBuilder#lt, calls setBoost(1.5f), adds a boolean "should"
} // <-- adds nested boolean as "must" clause to outer boolean
must(term('type', 'poetry')) // <-- uses CompassQueryBuilder#term, adds a boolean "must"
mustNot(term('theme', 'war')) // <-- uses CompassQueryBuilder#term, adds a boolean "mustNot"
ge('publishedDate', new Date() - 28)
sort(CompassQuery.SortImplicitType.SCORE) // <-- uses CompassQuery#addSort
sort('authorSurname', CompassQuery.SortPropertyType.STRING) // <-- uses CompassQuery#addSort
}
TODO document sequence of methods before/after implicit toQuery.
See the unit tests in CompassQueryBuilderTests for more examples.
Highlighting
The search and searchEvery methods support a withHighlighter option that allows you to provide a closure that is called for each search result hit.
The closure is called with the following parameters:
| highlighter | An instance of CompassHighlighter for that hit |
| index | The search result index |
| sr | The search-result object returned from the search or searchEvery method. In the case of search it is a Map, allowing you to store arbitrary data (the highlights). In the case of searchEvery it is the Collection of domain objects |
With the search method you might do:
// sr is the same Map returned by search, so store stuff in that def searchResult = Song.search("summer winds", withHighlighter: { highlighter, index, sr -> // lazy-init the storage if (!sr.highlights) { sr.highlights = [] } // store highlighted song lyrics; "lyrics" is a searchable-property of the Song domain class sr.highlights[index] = highlighter.fragment("lyrics") }) assert searchResult.highlights assert searchResult.highlights.size() == searchResult.results.size() assert (searchResult.highlights[0].indexOf("<b>summer</b>") > -1 || searchResult.highlights[0].indexOf("<b>winds</b>") > -1)
With the searchEvery method you might do:
// sr is the collection of matching domain classes, so we need to define the storage externally this time def highlights = [] def songs = Song.searchEvery("summer winds", withHighlighter: { highlighter, index, sr -> // store highlighted song lyrics; "lyrics" is a searchable-property of the Song domain class highlights[index] = highlighter.fragment("lyrics") }) assert highlights.size() == songs.size() assert highlights[0].indexOf("<b>summer</b>") > -1 || searchResult.highlights[0].indexOf("<b>winds</b>") > -1)
This works with both domain-class and SearchableService methods and both String and builder queries.