Case Study: Sphinx-based Search

Sphinx is a search server, and powers the search feature on many sites, including Yesod’s own site. While the actual code necessary to integrate Yesod with Sphinx is relatively short, it touches on a number of complicated topics, and is therefore a great case study in how to play with some of the under-the-surface details of Yesod.

There are essentially three different pieces at play here:

  • Storing the content we wish to search. This is fairly straight-forward Persistent code, and we won’t dwell on it much in this chapter.

  • Accessing Sphinx search results from inside Yesod. Thanks to the sphinx package, this is actually very easy.

  • Providing the document content to Sphinx. This is where the interesting stuff happens, and will show how to deal with streaming content from a database directly to XML, which gets sent directly over the wire to the client.

Sphinx Setup

Unlike many of our other examples, to start with here we’ll need to actually configure and run our external Sphinx server. I’m not going to go into all the details of Sphinx, partly because it’s not relevant to our point here, and mostly because I’m not an expert on Sphinx.

Sphinx provides three main command line utilities: searchd is the actual search daemon that receives requests from the client (in this case, our web app) and returns the search results. indexer parses the set of documents and creates the search index. search is a debugging utility that will run simple queries against Sphinx.

There are two important settings: the source and the index. The source tells Sphinx where to read document information from. It has direct support for MySQL and PostgreSQL, as well as a more general XML format known as xmlpipe2. We’re going to use the last one. This not only will give us more flexibility with choosing Persistent backends, but will also demonstrate some more powerful Yesod concepts.

The second setting is the index. Sphinx can handle multiple indices simultaneously, which allows it to provide search for multiple services at once. Each index will have a source it pulls from.

In our case, we’re going to provide a URL from our application (/search/xmlpipe) that provides the XML file required by Sphinx, and then pipe that through to the indexer. So we’ll add the following to our Sphinx config file:

  1. source searcher_src
  2. {
  3. type = xmlpipe2
  4. xmlpipe_command = curl http://localhost:3000/search/xmlpipe
  5. }
  6. index searcher
  7. {
  8. source = searcher_src
  9. path = /var/data/searcher
  10. docinfo = extern
  11. charset_type = utf-8
  12. }

In order to build your search index, you would run indexer searcher. Obviously this won’t work until you have your web app running. For a production site, it would make sense to run this command via a crontab script so the index is regularly updated.

Basic Yesod Setup

Let’s get our basic Yesod setup going. We’re going to have a single table in the database for holding documents, which consist of a title and content. We’ll store this in a SQLite database, and provide routes for searching, adding documents, viewing documents and providing the xmlpipe file to Sphinx.

  1. share [mkPersist sqlSettings, mkMigrate "migrateAll"] [persistUpperCase|
  2. Doc
  3. title Text
  4. content Textarea
  5. |]
  6. data Searcher = Searcher ConnectionPool
  7. mkYesod "Searcher" [parseRoutes|
  8. / RootR GET
  9. /doc/#DocId DocR GET
  10. /add-doc AddDocR POST
  11. /search SearchR GET
  12. /search/xmlpipe XmlpipeR GET
  13. |]
  14. instance Yesod Searcher
  15. instance YesodPersist Searcher where
  16. type YesodPersistBackend Searcher = SqlPersist
  17. runDB action = do
  18. Searcher pool <- getYesod
  19. runSqlPool action pool
  20. instance RenderMessage Searcher FormMessage where
  21. renderMessage _ _ = defaultFormMessage

Hopefully all of this looks pretty familiar by now. Next we’ll define some forms: one for creating documents, and one for searching:

  1. addDocForm :: Html -> MForm Searcher Searcher (FormResult Doc, Widget)
  2. addDocForm = renderTable $ Doc
  3. <$> areq textField "Title" Nothing
  4. <*> areq textareaField "Contents" Nothing
  5. searchForm :: Html -> MForm Searcher Searcher (FormResult Text, Widget)
  6. searchForm = renderDivs $ areq (searchField True) "Query" Nothing

The True parameter to searchField makes the field auto-focus on page load. Finally, we have some standard handlers for the homepage (shows the add document form and the search form), the document display, and adding a document.

  1. getRootR :: Handler RepHtml
  2. getRootR = do
  3. docCount <- runDB $ count ([] :: [Filter Doc])
  4. ((_, docWidget), _) <- runFormPost addDocForm
  5. ((_, searchWidget), _) <- runFormGet searchForm
  6. let docs = if docCount == 1
  7. then "There is currently 1 document."
  8. else "There are currently " ++ show docCount ++ " documents."
  9. defaultLayout [whamlet|
  10. <p>Welcome to the search application. #{docs}
  11. <form method=post action=@{AddDocR}>
  12. <table>
  13. ^{docWidget}
  14. <tr>
  15. <td colspan=3>
  16. <input type=submit value="Add document">
  17. <form method=get action=@{SearchR}>
  18. ^{searchWidget}
  19. <input type=submit value=Search>
  20. |]
  21. postAddDocR :: Handler RepHtml
  22. postAddDocR = do
  23. ((res, docWidget), _) <- runFormPost addDocForm
  24. case res of
  25. FormSuccess doc -> do
  26. docid <- runDB $ insert doc
  27. setMessage "Document added"
  28. redirect $ DocR docid
  29. _ -> defaultLayout [whamlet|
  30. <form method=post action=@{AddDocR}>
  31. <table>
  32. ^{docWidget}
  33. <tr>
  34. <td colspan=3>
  35. <input type=submit value="Add document">
  36. |]
  37. getDocR :: DocId -> Handler RepHtml
  38. getDocR docid = do
  39. doc <- runDB $ get404 docid
  40. defaultLayout $
  41. [whamlet|
  42. <h1>#{docTitle doc}
  43. <div .content>#{docContent doc}
  44. |]

Searching

Now that we’ve got the boring stuff out of the way, let’s jump into the actual searching. We’re going to need three pieces of information for displaying a result: the document ID it comes from, the title of that document, and the excerpts. Excerpts are the highlighted portions of the document which contain the search term.

Search Result

Case Study: Sphinx-based Search - 图1

So let’s start off by defining a Result datatype:

  1. data Result = Result
  2. { resultId :: DocId
  3. , resultTitle :: Text
  4. , resultExcerpt :: Html
  5. }

Next we’ll look at the search handler:

  1. getSearchR :: Handler RepHtml
  2. getSearchR = do
  3. ((formRes, searchWidget), _) <- runFormGet searchForm
  4. searchResults <-
  5. case formRes of
  6. FormSuccess qstring -> getResults qstring
  7. _ -> return []
  8. defaultLayout $ do
  9. toWidget [lucius|
  10. .excerpt {
  11. color: green; font-style: italic
  12. }
  13. .match {
  14. background-color: yellow;
  15. }
  16. |]
  17. [whamlet|
  18. <form method=get action=@{SearchR}>
  19. ^{searchWidget}
  20. <input type=submit value=Search>
  21. $if not $ null searchResults
  22. <h1>Results
  23. $forall result <- searchResults
  24. <div .result>
  25. <a href=@{DocR $ resultId result}>#{resultTitle result}
  26. <div .excerpt>#{resultExcerpt result}
  27. |]

Nothing magical here, we’re just relying on the searchForm defined above, and the getResults function which hasn’t been defined yet. This function just takes a search string, and returns a list of results. This is where we first interact with the Sphinx API. We’ll be using two functions: query will return a list of matches, and buildExcerpts will return the highlighted excerpts. Let’s first look at getResults:

  1. getResults :: Text -> Handler [Result]
  2. getResults qstring = do
  3. sphinxRes' <- liftIO $ S.query config "searcher" (unpack qstring)
  4. case sphinxRes' of
  5. ST.Ok sphinxRes -> do
  6. let docids = map (Key . PersistInt64 . ST.documentId) $ ST.matches sphinxRes
  7. fmap catMaybes $ runDB $ forM docids $ \docid -> do
  8. mdoc <- get docid
  9. case mdoc of
  10. Nothing -> return Nothing
  11. Just doc -> liftIO $ Just <$> getResult docid doc qstring
  12. _ -> error $ show sphinxRes'
  13. where
  14. config = S.defaultConfig
  15. { S.port = 9312
  16. , S.mode = ST.Any
  17. }

query takes three parameters: the configuration options, the index to search against (searcher in this case) and the search string. It returns a list of document IDs that contain the search string. The tricky bit here is that those documents are returned as Int64 values, whereas we need DocIds. We’re taking advantage of the fact that the SQL Persistent backends use a PersistInt64 constructor for their IDs, and simply wrap up the values appropriately.

If you’re dealing with a backend that has non-numeric IDs, like MongoDB, you’ll need to work out something a bit more clever than this.

We then loop over the resulting IDs to get a [Maybe Result] value, and use catMaybes to turn it into a [Result]. In the where clause, we define our local settings, which override the default port and set up the search to work when any term matches the document.

Let’s finally look at the getResult function:

  1. getResult :: DocId -> Doc -> Text -> IO Result
  2. getResult docid doc qstring = do
  3. excerpt' <- S.buildExcerpts
  4. excerptConfig
  5. [T.unpack $ escape $ docContent doc]
  6. "searcher"
  7. (unpack qstring)
  8. let excerpt =
  9. case excerpt' of
  10. ST.Ok bss -> preEscapedLazyText $ decodeUtf8With ignore $ L.concat bss
  11. _ -> ""
  12. return Result
  13. { resultId = docid
  14. , resultTitle = docTitle doc
  15. , resultExcerpt = excerpt
  16. }
  17. where
  18. excerptConfig = E.altConfig { E.port = 9312 }
  19. escape :: Textarea -> Text
  20. escape =
  21. T.concatMap escapeChar . unTextarea
  22. where
  23. escapeChar '<' = "&lt;"
  24. escapeChar '>' = "&gt;"
  25. escapeChar '&' = "&amp;"
  26. escapeChar c = T.singleton c

buildExcerpts takes four parameters: the configuration options, the textual contents of the document, the search index and the search term. The interesting bit is that we entity escape the text content. Sphinx won’t automatically escape these for us, so we must do it explicitly.

Similarly, the result from Sphinx is a list of lazy ByteStrings. But of course, we’d rather have Html. So we concat that list into a single lazy ByteString, decode it to a lazy text (ignoring invalid UTF-8 character sequences), and use preEscapedLazyText to make sure that the tags inserted for matches are not escaped. A sample of this HTML is:

  1. &#8230; Departments. The President shall have <span class='match'>Power</span> to fill up all Vacancies
  2. &#8230; people. Amendment 11 The Judicial <span class='match'>power</span> of the United States shall
  3. &#8230; jurisdiction. 2. Congress shall have <span class='match'>power</span> to enforce this article by
  4. &#8230; 5. The Congress shall have <span class='match'>power</span> to enforce, by appropriate legislation
  5. &#8230;

Streaming xmlpipe output

We’ve saved the best for last. For the majority of Yesod handlers, the recommended approach is to load up the database results into memory and then produce the output document based on that. It’s simpler to work with, but more importantly it’s more resilient to exceptions. If there’s a problem loading the data from the database, the user will get a proper 500 response code.

What do I mean by “proper 500 response code?” If you start streaming a response to a client, and encounter an exception halfway through, there’s no way to change the status code; the user will see a 200 response that simply stops in the middle. Not only can this partial content be confusing, but it’s an invalid usage of the HTTP spec.

However, generating the xmlpipe output is a perfect example of the alternative. There are potentially a huge number of documents (the yesodweb.com code handles tens of thousands of these), and documents could easily be several hundred kilobytes. If we take a non-streaming approach, this can lead to huge memory usage and slow response times.

So how exactly do we create a streaming response? As we cover in the WAI chapter, we have a ResponseSource constructor that uses a stream of blaze-builder Builders. From the Yesod side, we can avoid the normal Yesod response procedure and send a WAI response directly using the sendWaiResponse function. So there are at least two of the pieces of this puzzle.

Now we know we want to create a stream of Builders from some XML content. Fortunately, the xml-conduit package provides this interface directly. xml-conduit provides some high-level interfaces for dealing with documents as a whole, but in our case, we’re going to need to use the low-level Event interface to ensure minimal memory impact. So the function we’re interested in is:

  1. renderBuilder :: Resource m => RenderSettings -> Conduit Event m Builder b

In plain English, that means renderBuilder takes some settings (we’ll just use the defaults), and will then convert a stream of Events to a stream of Builders. This is looking pretty good, all we need now is a stream of Events.

Speaking of which, what should our XML document actually look like? It’s pretty simple, we have a sphinx:docset root element, a sphinx:schema element containing a single sphinx:field (which defines the content field), and then a sphinx:document for each document in our database. That last element will have an id attribute and a child content element.

Sample xmlpipe document

  1. <sphinx:docset xmlns:sphinx="http://sphinxsearch.com/">
  2. <sphinx:schema>
  3. <sphinx:field name="content"/>
  4. </sphinx:schema>
  5. <sphinx:document id="1">
  6. <content>bar</content>
  7. </sphinx:document>
  8. <sphinx:document id="2">
  9. <content>foo bar baz</content>
  10. </sphinx:document>
  11. </sphinx:docset>

Every document is going to start off with the same events (start the docset, start the schema, etc) and end with the same event (end the docset). We’ll start off by defining those:

  1. toName :: Text -> X.Name
  2. toName x = X.Name x (Just "http://sphinxsearch.com/") (Just "sphinx")
  3. docset, schema, field, document, content :: X.Name
  4. docset = toName "docset"
  5. schema = toName "schema"
  6. field = toName "field"
  7. document = toName "document"
  8. content = "content" -- no prefix
  9. startEvents, endEvents :: [X.Event]
  10. startEvents =
  11. [ X.EventBeginDocument
  12. , X.EventBeginElement docset []
  13. , X.EventBeginElement schema []
  14. , X.EventBeginElement field [("name", [X.ContentText "content"])]
  15. , X.EventEndElement field
  16. , X.EventEndElement schema
  17. ]
  18. endEvents =
  19. [ X.EventEndElement docset
  20. ]

Now that we have the shell of our document, we need to get the Events for each individual document. This is actually a fairly simple function:

  1. entityToEvents :: (Entity Doc) -> [X.Event]
  2. entityToEvents (Entity docid doc) =
  3. [ X.EventBeginElement document [("id", [X.ContentText $ toPathPiece docid])]
  4. , X.EventBeginElement content []
  5. , X.EventContent $ X.ContentText $ unTextarea $ docContent doc
  6. , X.EventEndElement content
  7. , X.EventEndElement document
  8. ]

We start the document element with an id attribute, start the content, insert the content, and then close both elements. We use toPathPiece to convert a DocId into a Text value. Next, we need to be able to convert a stream of these entities into a stream of events. For this, we can use the built-in concatMap function from Data.Conduit.List: CL.concatMap entityToEvents.

But what we really want is to stream those events directly from the database. For most of this book, we’ve used the selectList function, but Persistent also provides the (more powerful) selectSourceConn function. So we end up with the function:

  1. docSource :: Connection -> C.Source (C.ResourceT IO) X.Event
  2. docSource conn = selectSourceConn conn [] [] C.$= CL.concatMap entityToEvents

The $= operator joins together a source and a conduit into a new source. Now that we have our Event source, all we need to do is surround it with the document start and end events. With Source‘s Monoid instance, this is a piece of cake:

  1. fullDocSource :: Connection -> C.Source (C.ResourceT IO) X.Event
  2. fullDocSource conn = mconcat
  3. [ CL.sourceList startEvents
  4. , docSource conn
  5. , CL.sourceList endEvents
  6. ]

We’re almost there, now we just need to tie it together in getXmlpipeR. We need to get a database connection to be used. Normally, database connections are taken and returned automatically via the runDB function. In our case, we want to check out a connection and keep it available until the response body is completely sent. To do this, we use the takeResource function, which registers a cleanup action with the ResourceT monad.

All WAI applications live in a ResourceT transformer. You can get more information on ResourceT in the conduit appendix.

By default, a resource will not be returned to the pool. This has to do with proper exception handling, but is not relevant for our use case. Therefore, we need to force the connection to be returned to the pool.

  1. getXmlpipeR :: Handler RepXml
  2. getXmlpipeR = do
  3. Searcher pool <- getYesod
  4. let headers = [("Content-Type", "text/xml")]
  5. managedConn <- lift $ takeResource pool
  6. let conn = mrValue managedConn
  7. lift $ mrReuse managedConn True let source = fullDocSource conn C.$= renderBuilder def
  8. sendWaiResponse $ ResponseSource status200 headers source

We get our connection pool from the foundation variable, then send a WAI response. We use the ResponseSource constructor, and provide it the status code, response headers, and body.

Full code

  1. {-# LANGUAGE OverloadedStrings, TypeFamilies, TemplateHaskell,
  2. QuasiQuotes, MultiParamTypeClasses, GADTs, FlexibleContexts
  3. #-}
  4. import Yesod
  5. import Data.Text (Text, unpack)
  6. import Control.Applicative ((<$>), (<*>))
  7. import Database.Persist.Sqlite
  8. import Database.Persist.Query.GenericSql (selectSourceConn)
  9. import Database.Persist.Store (PersistValue (PersistInt64))
  10. import qualified Text.Search.Sphinx as S
  11. import qualified Text.Search.Sphinx.Types as ST
  12. import qualified Text.Search.Sphinx.ExcerptConfiguration as E
  13. import qualified Data.ByteString.Lazy as L
  14. import Data.Text.Lazy.Encoding (decodeUtf8With)
  15. import Data.Text.Encoding.Error (ignore)
  16. import Data.Maybe (catMaybes)
  17. import Control.Monad (forM)
  18. import qualified Data.Text as T
  19. import Text.Blaze (preEscapedLazyText)
  20. import qualified Data.Conduit as C
  21. import qualified Data.Conduit.List as CL
  22. import qualified Data.XML.Types as X
  23. import Network.Wai (Response (ResponseSource))
  24. import Network.HTTP.Types (status200)
  25. import Text.XML.Stream.Render (renderBuilder, def)
  26. import Data.Monoid (mconcat)
  27. import Data.Conduit.Pool (takeResource, mrValue, mrReuse)
  28. share [mkPersist sqlSettings, mkMigrate "migrateAll"] [persistUpperCase|
  29. Doc
  30. title Text
  31. content Textarea
  32. |]
  33. data Searcher = Searcher ConnectionPool
  34. mkYesod "Searcher" [parseRoutes|
  35. / RootR GET
  36. /doc/#DocId DocR GET
  37. /add-doc AddDocR POST
  38. /search SearchR GET
  39. /search/xmlpipe XmlpipeR GET
  40. |]
  41. instance Yesod Searcher
  42. instance YesodPersist Searcher where
  43. type YesodPersistBackend Searcher = SqlPersist
  44. runDB action = do
  45. Searcher pool <- getYesod
  46. runSqlPool action pool
  47. instance RenderMessage Searcher FormMessage where
  48. renderMessage _ _ = defaultFormMessage
  49. addDocForm :: Html -> MForm Searcher Searcher (FormResult Doc, Widget)
  50. addDocForm = renderTable $ Doc
  51. <$> areq textField "Title" Nothing
  52. <*> areq textareaField "Contents" Nothing
  53. searchForm :: Html -> MForm Searcher Searcher (FormResult Text, Widget)
  54. searchForm = renderDivs $ areq (searchField True) "Query" Nothing
  55. getRootR :: Handler RepHtml
  56. getRootR = do
  57. docCount <- runDB $ count ([] :: [Filter Doc])
  58. ((_, docWidget), _) <- runFormPost addDocForm
  59. ((_, searchWidget), _) <- runFormGet searchForm
  60. let docs = if docCount == 1
  61. then "There is currently 1 document."
  62. else "There are currently " ++ show docCount ++ " documents."
  63. defaultLayout [whamlet|
  64. <p>Welcome to the search application. #{docs}
  65. <form method=post action=@{AddDocR}>
  66. <table>
  67. ^{docWidget}
  68. <tr>
  69. <td colspan=3>
  70. <input type=submit value="Add document">
  71. <form method=get action=@{SearchR}>
  72. ^{searchWidget}
  73. <input type=submit value=Search>
  74. |]
  75. postAddDocR :: Handler RepHtml
  76. postAddDocR = do
  77. ((res, docWidget), _) <- runFormPost addDocForm
  78. case res of
  79. FormSuccess doc -> do
  80. docid <- runDB $ insert doc
  81. setMessage "Document added"
  82. redirect $ DocR docid
  83. _ -> defaultLayout [whamlet|
  84. <form method=post action=@{AddDocR}>
  85. <table>
  86. ^{docWidget}
  87. <tr>
  88. <td colspan=3>
  89. <input type=submit value="Add document">
  90. |]
  91. getDocR :: DocId -> Handler RepHtml
  92. getDocR docid = do
  93. doc <- runDB $ get404 docid
  94. defaultLayout $
  95. [whamlet|
  96. <h1>#{docTitle doc}
  97. <div .content>#{docContent doc}
  98. |]
  99. data Result = Result
  100. { resultId :: DocId
  101. , resultTitle :: Text
  102. , resultExcerpt :: Html
  103. }
  104. getResult :: DocId -> Doc -> Text -> IO Result
  105. getResult docid doc qstring = do
  106. excerpt' <- S.buildExcerpts
  107. excerptConfig
  108. [T.unpack $ escape $ docContent doc]
  109. "searcher"
  110. (unpack qstring)
  111. let excerpt =
  112. case excerpt' of
  113. ST.Ok bss -> preEscapedLazyText $ decodeUtf8With ignore $ L.concat bss
  114. _ -> ""
  115. return Result
  116. { resultId = docid
  117. , resultTitle = docTitle doc
  118. , resultExcerpt = excerpt
  119. }
  120. where
  121. excerptConfig = E.altConfig { E.port = 9312 }
  122. escape :: Textarea -> Text
  123. escape =
  124. T.concatMap escapeChar . unTextarea
  125. where
  126. escapeChar '<' = "&lt;"
  127. escapeChar '>' = "&gt;"
  128. escapeChar '&' = "&amp;"
  129. escapeChar c = T.singleton c
  130. getResults :: Text -> Handler [Result]
  131. getResults qstring = do
  132. sphinxRes' <- liftIO $ S.query config "searcher" (unpack qstring)
  133. case sphinxRes' of
  134. ST.Ok sphinxRes -> do
  135. let docids = map (Key . PersistInt64 . ST.documentId) $ ST.matches sphinxRes
  136. fmap catMaybes $ runDB $ forM docids $ \docid -> do
  137. mdoc <- get docid
  138. case mdoc of
  139. Nothing -> return Nothing
  140. Just doc -> liftIO $ Just <$> getResult docid doc qstring
  141. _ -> error $ show sphinxRes'
  142. where
  143. config = S.defaultConfig
  144. { S.port = 9312
  145. , S.mode = ST.Any
  146. }
  147. getSearchR :: Handler RepHtml
  148. getSearchR = do
  149. ((formRes, searchWidget), _) <- runFormGet searchForm
  150. searchResults <-
  151. case formRes of
  152. FormSuccess qstring -> getResults qstring
  153. _ -> return []
  154. defaultLayout $ do
  155. toWidget [lucius|
  156. .excerpt {
  157. color: green; font-style: italic
  158. }
  159. .match {
  160. background-color: yellow;
  161. }
  162. |]
  163. [whamlet|
  164. <form method=get action=@{SearchR}>
  165. ^{searchWidget}
  166. <input type=submit value=Search>
  167. $if not $ null searchResults
  168. <h1>Results
  169. $forall result <- searchResults
  170. <div .result>
  171. <a href=@{DocR $ resultId result}>#{resultTitle result}
  172. <div .excerpt>#{resultExcerpt result}
  173. |]
  174. getXmlpipeR :: Handler RepXml
  175. getXmlpipeR = do
  176. Searcher pool <- getYesod
  177. let headers = [("Content-Type", "text/xml")]
  178. managedConn <- lift $ takeResource pool
  179. let conn = mrValue managedConn
  180. lift $ mrReuse managedConn True
  181. let source = fullDocSource conn C.$= renderBuilder def
  182. flushSource = C.mapOutput C.Chunk source
  183. sendWaiResponse $ ResponseSource status200 headers flushSource
  184. entityToEvents :: (Entity Doc) -> [X.Event]
  185. entityToEvents (Entity docid doc) =
  186. [ X.EventBeginElement document [("id", [X.ContentText $ toPathPiece docid])]
  187. , X.EventBeginElement content []
  188. , X.EventContent $ X.ContentText $ unTextarea $ docContent doc
  189. , X.EventEndElement content
  190. , X.EventEndElement document
  191. ]
  192. fullDocSource :: Connection -> C.Source (C.ResourceT IO) X.Event
  193. fullDocSource conn = mconcat
  194. [ CL.sourceList startEvents
  195. , docSource conn
  196. , CL.sourceList endEvents
  197. ]
  198. docSource :: Connection -> C.Source (C.ResourceT IO) X.Event
  199. docSource conn = selectSourceConn conn [] [] C.$= CL.concatMap entityToEvents
  200. toName :: Text -> X.Name
  201. toName x = X.Name x (Just "http://sphinxsearch.com/") (Just "sphinx")
  202. docset, schema, field, document, content :: X.Name
  203. docset = toName "docset"
  204. schema = toName "schema"
  205. field = toName "field"
  206. document = toName "document"
  207. content = "content" -- no prefix
  208. startEvents, endEvents :: [X.Event]
  209. startEvents =
  210. [ X.EventBeginDocument
  211. , X.EventBeginElement docset []
  212. , X.EventBeginElement schema []
  213. , X.EventBeginElement field [("name", [X.ContentText "content"])]
  214. , X.EventEndElement field
  215. , X.EventEndElement schema
  216. ]
  217. endEvents =
  218. [ X.EventEndElement docset
  219. ]
  220. main :: IO ()
  221. main = withSqlitePool "searcher.db3" 10 $ \pool -> do
  222. runSqlPool (runMigration migrateAll) pool
  223. warpDebug 3000 $ Searcher pool