How to build a mail search engine using Nitro

By Kashia.

Part 7 - C like Controller

The controller is the the part of Nitro which provides the general logic of how your application functions. So: more background logic, before we can see anything at all.

Have a look at the run.rb again, and you can see 2 controllers there, the MainController and the MailController.

Main Controller

Let's start with an easy example, which is why I introduced the MainController in the first place.

# Class MailController
class MailController < Nitro::Controller
  def index
    print "<a href='/mail'>List mails</a>"
  end
end

This is, how every Nitro controller is built. The controller is a class derived from Nitro::Controller. Every method, like index (which is a special case, it points to the root: /), maps to an URL.

Let's map out our Nitro application:

  • MainController (mount point: '/')
    • def index => '/'
  • MailController (mount point: '/mail')
    • def index => '/mail'
    • def view(msg_id) => '/mail/view'
    • def search => '/mail/search'

Now, that was real easy, wasn't it?

Mail Controller

The mail controller is just as easy. This will also be the part where we make use of some nifty Og features.

Let's go through the code line by line, because this one is important.

3 helper :pager

This provides a handy feature, the paginate() function, with which you can easily distribute huge data amounts over several pages.

def index

This function gets called, when you browse to http://localhost:9999/mail.

6 @entries, @pager = paginate(IndexedMail, :per_page => 10, :order => 'date DESC')

This line creates an array called @entries, and a @pager object. We will use the @pager object later, in our templates, to get the number of the pages and 'forward' and 'backward' links to browse the Mails.

def view(oid)

When you browse to http://localhost:9999/mail/view/5, or any other number after view/ which is a valid oid in the database, you will get a single mail to read.

As you can see, the method view accepts a single parameter, for example you would not be able to call /mail/view/4/asdf. It would raise an ArgumentError: wrong number of arguments.

So, for each parameter, you can get a value. If you want an arbitrary number of arguments, use def function(*args), like elsewhere in ordinary Ruby code.

12 flash[:err] = 'No such Email'
13     redirect_to 'index'

The 'flash' is a special object, which provides a way to pass information to the next rendered page. This is particularily useful for error messages, which you want to keep even after doing a redirect to another page.

The redirect_to 'index' redirects to /mail/index but you could also create a special error page to handle the error more explicitly.

After the check if the oid is an actual number greater than zero, we fetch the IndexedMail object we want to display.

16 @mail = IndexedMail[oid]

This stores the oid'th Mail in the variable @mail.

You might start seeing the pattern here, everything I want to pass to the View later is a instance variable. In the chapter about Views you will see that you can just use those instance variables as if you were directly in that Controller class.

As you can see, the view function does nothing else, apart from some error handling, than getting an IndexedMail object and storing it in an instance variable.

def search

Above you have seen that you can call controller methods via the browser's URL. Now, why doesn't the search method have any parameters?

The reason is simple: I want to not only catch GET requests, I want to get POSTs as well. By using the request facility you can get the parameters you want.

If you want to make sure, or just want to check which request method is used, you can use request.post? or request.get? or just request.method.

26 if @query = request['q'] || request['search'] || request['query']

This line means: 'if there is a ?q=data, a ?search=data or a ?query=data appended to your GET request or in you POST body, it will use it as the @query.

Example: http://localhost:9999/mail/search?query=chunky%20bacon

27 @query.gsub!(/\b\s+\b/, '|')   # spaces between words

Well, well, here we got some regex fun again :D. This is a convenience workaround to be able to use more intuitive queries for searching.

About search queries:

Parses a query, which should be single words separated by the boolean operators "&" and, "|" or, and "!" not, which can be grouped using parenthesis.

That means there must always be a symbol between two words. With the regex I just work around that and treat whitespace between two words as a OR.

Boring, yeah, next up: something very neat :D

29 sql_query = "SELECT * FROM findogindexedmail('#{Og.escape(@query)}') ORDER BY rank LIMIT 5"

HAH! See why we built some neat database first? Now we actually call our findogindexedmail('query') function from before. Og.escape is something you should remember and make use of, if you want pass any data from users (evil!) to the database. Always take some precaution against injection! Who knows where that needle has been ;/.

31 @results = IndexedMail.find(:sql => sql_query, :select => '*')

Here we search for results and put them into a @results array. It executes the query we created before. If you already used Og a bit before, you may know about the :sql option, which lets you execute arbitrary SQL. But you most probably don't use or know the :select option. It gets fields which are not part of the Model we defined before. We use the :select to get the rank field as well as the others.

Now we have everything together, we have search results at our fingertips, we just have to provide some interface to them, something human-viewable anyway (are programmers humans...?).

MailController code

 1 # Class MailController
 2 class MailController < Nitro::Controller
 3   helper :pager
 4 
 5   def index
 6     @entries, @pager = paginate(IndexedMail, :per_page => 10, :order => 'date DESC')
 7   end
 8 
 9   def view(oid = nil)
10     if (!oid || oid.to_i <= 0)
11       flash[:err] = 'No such Email'
12       redirect_to 'index'
13     end
14     
15     @mail = IndexedMail[oid]
16     
17     unless @mail
18       flash[:err] = 'No such Email'
19       redirect_to 'index'
20     end
21   
22   end
23   
24   def search
25     if @query = request['q'] || request['search'] || request['query']
26       @query.gsub!(/\b\s+\b/, '|') # spaces between words
27       
28       sql_query = "SELECT * FROM findogindexedmail('#{Og.escape(@query)}') ORDER BY rank LIMIT 5"
29       
30       @results = IndexedMail.find(:sql => sql_query, :select => '*')
31     
32     end
33   
34   end
35 
36 end # end class MailController

Heh, that was quite some stuff, I hope I haven't scared you away by now. But this also shows the one of my most beloved Nitro features: It doesn't get in my way while I code. I just define some functions in a Ruby class, write workarounds, mess with anything I like. Nitro mostly just gives me more handy tools to work with, and if I don't like the proposed tools, I just write my own.

That actually wraps up the need for groundwork, we now have all that data lying around, crying to be used.

Standard SQL

You remember the phrase, that the controller does all the heavy lifting? Ok I lied. Heavy lifting does the poor Database System T_T You scare little kids too, right? ;/

Basically we have 2 options, if you have PostgreSQL (good) you're lucky, if not (bad), you'll either have to search for regex (yay!) functions yourself or take my little query string builder using the LIKE keyword in standard SQL.

25 def search
26   if @query = request['q'] || request['search'] || request['query']
27     @query.gsub!(/[^\w]+/, '|') # replace everything wich is not a word char
28     
29     sql_query = "body ~* '(#{@query})' OR header ~* '(#{@query})'"
30     
31     @results = IndexedMail.find(:condition => sql_query)
32   
33   end
34 end

This was the version with the nice regex system from PostgreSQL, look at the ~*, ain't it nice :D.

Another version would be:

25 def search
26   if @query = request['q'] || request['search'] || request['query']
27     @query = @query.split(/[^\w]+/) # split at any non word character
28     
29     sql_query = @query.map {|x| "body LIKE '%#{x}%'" }.join(" OR ") + ' OR ' +
30                 @query.map {|x| "header LIKE '%#{x}%'" }.join(" OR ")
31     
32     @results = IndexedMail.find(:condition => sql_query)
33   
34   end
35 end

I think you can understand now, while I'm really all for standards, that sometimes convenience functions and or some special handling can lead to cleaner code in the frontend.

If you think a little, you can even make your own ranking system (count the occurance of the query words in the resulting mails perhaps?) and sort them. This is left as an exercise to the reader.

first
last