Searchable Plugin - Searching

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

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

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

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

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.



Previous - SearchableController and view

Up - Searchable Plugin

Next - Mapping

Labels

 
(None)