Skip to content

Instantly share code, notes, and snippets.

@jeroenr
Last active December 20, 2015 23:19
Show Gist options
  • Save jeroenr/6211931 to your computer and use it in GitHub Desktop.
Save jeroenr/6211931 to your computer and use it in GitHub Desktop.
Multi select faceted search on dynamic index with Play!, scalastic and elastic search
define ["jquery", "underscore"], ($, _) ->
delay = (ms, func) -> setTimeout func, ms
$('#facetForm input[type="checkbox"]').click (e)->
clearTimeout(window.timeoutHandle) if window.timeoutHandle
window.timeoutHandle = delay 1337, ->
$('#facetForm').submit()
import sbt._
import Keys._
import play.Project._
object ApplicationBuild extends Build {
val appName = "tweetsearch"
val appVersion = "1.0-SNAPSHOT"
val appDependencies = Seq(
// Add your project dependencies here,
"org.scalastic" %% "scalastic" % "0.90.0"
)
val main = play.Project(appName, appVersion, appDependencies).settings(
// Add your own project settings here
requireJs += "main.js"
)
}
package models
import org.elasticsearch.index.query.QueryBuilder
import org.elasticsearch.search.facet.FacetBuilder
import org.elasticsearch.search.facet.terms.TermsFacet
import scala.collection.Map
import collection.immutable.{Map => ImmutableMap}
import org.elasticsearch.index.query.QueryBuilders._
import org.elasticsearch.index.query.FilterBuilders._
import org.elasticsearch.search.facet.FacetBuilders._
import scalastic.elasticsearch.Indexer
import collection.JavaConversions._
object ElasticSearchService {
val indexer = Indexer.transport(ImmutableMap("cluster.name" -> "elasticsearch"))
val FIELDS = "@fields"
def buildElasticSearchMultiSelectFacetQuery(fieldNames: Iterable[String], multiValueMap: Map[String, Seq[String]]) = {
val termsFilterMap = multiValueMap.map {
case (fieldName, values) => fieldName -> termsFilter(fieldName, values.toSeq: _*)
}
val facetQueries = fieldNames.map {
case fieldName => termsFacet(fieldName).field(fieldName).facetFilter(andFilter((termsFilterMap - fieldName).values.toSeq: _*))
}
(filteredQuery(matchAllQuery, andFilter(termsFilterMap.values.toSeq: _*)), facetQueries)
}
def executeElasticSearchQuery(indexName: String, queryBuilder: QueryBuilder, facetQueryBuilder: Iterable[FacetBuilder]) = {
val search = indexer.search(Seq(indexName), query = queryBuilder, facets = facetQueryBuilder)
val facetResults = search.getFacets().getFacets().map {
case (facetName, facet: TermsFacet) => facetName -> facet.map(facetEntry => (facetEntry.getTerm.string(), facetEntry.getCount))
}.filter(_._2.nonEmpty)
val results = search.getHits().hits().toSeq.map(_.sourceAsMap().toMap)
(results.map(_(FIELDS).asInstanceOf[java.util.HashMap[String,AnyRef]].toMap), facetResults)
}
def getFieldNames(indexName: String, documentType: String) = {
val metaDataMap = indexer.metadataFor(indexName).mapping(documentType).sourceAsMap()
val map = metaDataMap("properties").asInstanceOf[java.util.LinkedHashMap[String, AnyRef]](FIELDS).asInstanceOf[java.util.LinkedHashMap[String, AnyRef]]("properties").asInstanceOf[java.util.LinkedHashMap[String, AnyRef]]
map.toMap.keys
}
}
@(message: String, fieldNames: Iterable[String], results: Iterable[Map[String,Any]] = Seq(), facets: (Seq[String],scala.collection.Map[String, Iterable[(String, Int)]]) = (Seq(),Map()))
@main("Search form", message) {
<div id="nav">
</div>
<div id="main" >
<div class="panel">
@if(!results.isEmpty) {
<table>
@for(result <- results) {
<tr>@result.values.map { v =>
<td>@v</td>
}
</tr>
}
</table>
} else {
<span>No results</span>
}
</div>
</div>
<div id="sidebar" >
@if(!results.isEmpty) {
<div class="panel">
@helper.form(action = routes.SearchController.facetedSearch, 'id -> "facetForm") {
@for((name, facetEntries) <- facets._2) {
<legend>@utils.ViewHelper.humanReadable(name)</legend>
<ul>
@for((term, count) <- facetEntries) {
@if(facets._1.contains(term)) {
<li><input type="checkbox" name="@name" value="@term" checked="checked"><span>@term </span><em> (@count)</em></input></li>
} else {
<li><input type="checkbox" name="@name" value="@term" ><span>@term </span><em> (@count)</em></input></li>
}
}
</ul>
}
}
</div>
}
</div>
<div id="footer">
</div>
}
require.config
optimize: "uglify2"
body {
margin: 0 auto;
font-family: Verdana, Arial, Helvetica, sans-serif;
font-size: 9pt;
}
ul {
padding:0;
margin:0;
& li {
list-style-type: none;
padding:0;
margin:0;
}
}
a {
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
#wrap {
margin: 0 auto;
width: 84em;
}
#header {
}
#nav {
clear:both;
padding:5px 10px;
}
#main {
float:right;
width:800px;
}
h2 {
margin:0 0 1em;
}
#sidebar {
float:left;
width:200px;
}
#footer {
clear:both;
padding:5px 10px;
}
#footer p {
margin:0;
}
@(title: String, message: String)(content: Html)
<!DOCTYPE html>
<html>
<head>
<title>@title</title>
<link rel="stylesheet" media="screen" href="@routes.Assets.at("stylesheets/main.css")">
<link rel="shortcut icon" type="image/png" href="@routes.Assets.at("images/favicon.png")">
</head>
<body>
<div id="header">
<div id="logo">
</div>
</div>
<div id="wrap">
@content
@helper.requireJs(core = routes.Assets.at("javascripts/require.js").url, module = routes.Assets.at("javascripts/main").url)
</div>
</body>
</html>
# Routes
# This file defines all application routes (Higher priority routes first)
# ~~~~
# Home page
GET / controllers.SearchController.index
POST /facets controllers.SearchController.facetedSearch
# Map static resources from the /public folder to the /assets URL path
GET /assets/*file controllers.Assets.at(path="/public", file)
package controllers
import play.api.mvc.{Action, Controller}
import models._
object SearchController extends Controller {
val INDEX = "tweets"
val TITLE = "You know, for search"
def index = Action {
implicit request => {
val fieldNames = ElasticSearchService.getFieldNames(INDEX, "tweet")
generateSearchResultView(fieldNames.map(_ -> Seq("")).toMap)
}
}
def facetedSearch = Action {
implicit request => {
val formValues = request.body.asFormUrlEncoded.get
val fieldNames = ElasticSearchService.getFieldNames(INDEX, "tweet")
val (queryBuilder, facetQueryBuilder) = ElasticSearchService.buildElasticSearchMultiSelectFacetQuery(fieldNames, formValues)
val (results, facetResults) = ElasticSearchService.executeElasticSearchQuery(INDEX, queryBuilder, facetQueryBuilder)
Ok(views.html.index(TITLE, fieldNames, results, (formValues.values.flatten.toSeq, facetResults)))
}
}
private def generateSearchResultView(multiValueMap: Map[String, Seq[String]]) = {
val (fieldNames, queryBuilder, facetQueryBuilder) = ElasticSearchService.buildElasticSearchQuery(multiValueMap)
val (results, facetResults) = ElasticSearchService.executeElasticSearchQuery(INDEX, queryBuilder, facetQueryBuilder)
Ok(views.html.index(TITLE, fieldNames, results, (Seq(), facetResults)))
}
}
package utils
import java.util.Locale
object ViewHelper {
val NON_HUMAN_STRING_REGEX = """(?<=[A-Z])(?=[A-Z][a-z])|(?<=[^A-Z])(?=[A-Z])|(?<=[A-Za-z])(?=[^A-Za-z])""".r
def humanReadable(s: String, locale: Locale = Locale.getDefault) = NON_HUMAN_STRING_REGEX.replaceAllIn(s, " ").toLowerCase(locale).capitalize
}
@jeroenr
Copy link
Author

jeroenr commented Aug 12, 2013

Tricky part was termsFilter(fieldName, values.toSeq: __). Without the splat (: __) it was passing in values (of type $colon$colon) as a single argument to the termsFilter(String, Object...) overloaded function, which causes an exception while creating the search query

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment