Monday, 10 November 2008

Ramaze - first impressions continued

My earlier posting was getting too long, so I'm continuing in this new one. Meanwhile, the tutorial's author has kindly left a comment on the original posting, indicating that the tutorial is due to be updated pretty soon. I look forward to that!

Chapter 11 of the tutorial can be used pretty much as-is, but I found that after an error, the redirection specified by the error method would be overwritten by the helper aspect. After some research, I discovered that the latest version of Ramaze supports a redirection status operator, redirected?. Using this, the helper aspect can be written very simply:
helper :aspect
after( :create, :delete, :open, :close ) {
redirect Rs() unless redirected?
}
Chapter 12 of the tutorial needs no changes, except that you don't have to add the flash section to the page template because we did it earlier.

Now I wanted to convert the application to something more suited to enterprise deployment (and also more suited to shared hosting deployment - typically such environments provide an Apache server and MySQL database). So the first thing to do was to move from YAML to SQL - I chose MySQL.

If you haven't yet installed MySQL, do so before the next step. Take care to ensure that your PATH environment variable contains the MySQL binary as well as its libraries (hold down the Windows key and press PAUSE to bring up the system properties, then choose Environment Variables in the Advanced tab). For example, on my system the System PATH begins:
C:\ruby\bin;
C:\Program Files\MySQL\MySQL Server 5.0\bin;
C:\Program Files\MySQL\MySQL Server 5.0\lib\opt;
C:\Program Files\MySQL\MySQL Server 5.0\lib\debug;
...
Add a new database for the application and create a user account named "ramaze" with password "TodoList" for both localhost and remote-host access:
mysql -u root -p
******
CREATE DATABASE IF NOT EXISTS todolist_db;
GRANT SELECT, INSERT, UPDATE, DELETE, CREATE,
DROP, RELOAD, PROCESS, FILE, REFERENCES, INDEX,
ALTER, SHOW DATABASES, CREATE TEMPORARY TABLES,
LOCK TABLES, EXECUTE, CREATE VIEW, SHOW VIEW,
CREATE ROUTINE, ALTER ROUTINE ON *.*
TO 'ramaze'@'localhost'
IDENTIFIED BY 'TodoList' WITH GRANT OPTION;
GRANT SELECT, INSERT, UPDATE, DELETE, CREATE,
DROP, RELOAD, PROCESS, FILE, REFERENCES, INDEX,
ALTER, SHOW DATABASES, CREATE TEMPORARY TABLES,
LOCK TABLES, EXECUTE, CREATE VIEW, SHOW VIEW,
CREATE ROUTINE, ALTER ROUTINE ON *.*
TO 'ramaze'@'%'
IDENTIFIED BY 'TodoList' WITH GRANT OPTION;
quit
I decided to use Sequel as my database access layer. You probably also have to install the gems mysql and sequel before the code will work - I am not sure about the mysql gem, as it was one of the things I installed before I eventually discovered the need to set up the PATH correctly.
gem install sequel
gem install mysql
I tried choosing the 'ruby' option, but this was unable to generate the native code on my machine. So I uninstalled it and tried again, choosing the 'mswin32' option, which worked fine.

Now take a safety copy of your model file, todolist.rb, and start modifying it to use MySQL instead of YAML. Replace the following lines:
require 'ramaze/store/default'
TodoList = Ramaze::Store::Default.new('todolist.yaml')
with the following lines:
require 'rubygems'
require 'sequel'

DB = Sequel.mysql('todolist_db',
:user => 'ramaze',
:password => 'TodoList',
:host => 'localhost')

class TodoList < Sequel::Model(:tasks)
set_schema do
primary_key :id
varchar :title, unique => true, :null => false
boolean :done
end
end
To begin with, I tried using the title as the primary key. But I soon found that not only was it necessary to define the title field first and then separately to name it as the primary key, the user-supplied title was not always suitable as a key value due to the presence of shell metacharacters and so on. So I decided to go with the Sequel flow and use a system-generated ID as the primary key.

Next I found that the model didn't support the method original(), which the original simple model supports. So I supplied one myself (subsequently found to be unnecessary after refactoring main.rb):
  # Copy all records into a list
def self.original
tasks = []
self.dataset.each {|r| tasks.push [r[:title], {:done => r[:done]}]}
return tasks
end
More important was to add some initialisation code after the class was defined:
unless TodoList.table_exists?
DB.transaction do
puts "Creating table 'tasks'\n"
TodoList.create_table
end
end
Now try running the application. It seems to work, but nothing gets stored in the database. At this point we have to bite the bullet and refactor the main module to support a more relational view of the underlying data model.

In the index() method, we'll start using the ID of each task to identify it instead of the title. Instead of requesting an array of key-value pairs from the original() method, we get an array of task objects from the dataset() method. That of course implies that we have to extract the title and the ID from the task object and use them as appropriate:
  def index
@title = ["To-Do List"]
@tasks = []
TodoList.dataset.each do |task|
id = task[:id]
title = task[:title]
if task[:done]
status = 'done'
toggle = A('Open Task', :href => Rs(:open, id))
else
status = 'not done'
toggle = A('Close Task', :href => Rs(:close, id))
end
delete = A('Delete', :href => Rs(:delete, id))
@tasks << [title, status, toggle, delete]
end
@tasks.sort!
end
The methods open(), close(), task_status() and delete() have to change because they all now take an id as parameter:
  def delete id
unless TodoList.delete id
failed "Cannot delete task no.: #{id}"
end
end

def open id
task_status id, false
end

def close id
task_status id, true
end

def task_status id, status
unless task = TodoList[id]
failed "No such task no.: #{id}"
redirect_referer
end
task[:done] = status
TodoList[id] = task
end
I decided to override the []= method of the model, so that tasks would actually be written to the database. It was an easy step from there to create a new row in the tasks table whenever the ID parameter to this method was absent or nil. So the create() method becomes:
  def create
if title = request['title']
title.strip!
if title.empty?
failed("Please enter a title")
redirect '/new'
end
if TodoList.find(:title => title)
failed("Task '#{title}' already exists")
else
TodoList[nil] = {:title => title, :done => false}
end
end
end
Note the check for duplicates, which is easy to do now that we can look up titles in the dataset.

Turning to the file todolist.rb, here are the methods I had to add to allow tasks to be added and deleted in the database:
  def self.delete(id)
puts "Attempting to delete '#{id}'\n"
DB.transaction do
if task = TodoList.find(:id => id)
task.destroy()
else
puts "Not found\n"
return false
end
end
end

# Assignment should update the underlying database
def self.[]=(id, values)
DB.transaction do
if (id == nil || !(task = TodoList.find(:id => id)))
task = TodoList.new
end
task.title = values[:title]
task.done = values[:done]
task.save
end
end
That's pretty much it. But before I stopped, I wanted to prettify the user interface a bit. I didn't like the fact that the column widths tended to change whenever the sole "not done" item was added to or deleted from the list, and in any case I preferred clickable icons to text links. So I designed some icons:

not done

done

delete

Feel free to copy the icon images. To let the Mongrel server access these, they have to be placed in the folder "public" of your project. Then I redesigned the index() method and the corresponding index.html file very slightly to use these (first declaring some constants):
  DELETE_ICON  = '<img src="delete_sml.gif">'
NOTDONE_ICON = '<img src="notdone_sml.gif">'
DONE_ICON = '<img src="done_sml.gif">'

# the index action is called automatically when no other action is specified
def index
@title = ["To-Do List"]
@tasks = []
TodoList.dataset.each do |task|
id = task[:id]
title = task[:title]
if task[:done]
toggle = A(DONE_ICON, :href => Rs(:open, id))
else
toggle = A(NOTDONE_ICON, :href => Rs(:close, id))
end
delete = A(DELETE_ICON, :href => Rs(:delete, id))
@tasks << [title, toggle, delete]
end
@tasks.sort!
end

<p><a href="/new">New Task</a></p>
<?r if @tasks.empty? ?>
<p>No Tasks</p>
<?r else ?>
<table>
<?r @tasks.each do |title, toggle, delete| ?>
<tr>
<td class="title" > #{title} </td>
<td class="toggle"> #{toggle} </td>
<td class="delete"> #{delete} </td>
</tr>
<?r end ?>
</table>
<?r end ?>
The full source code is attached in the comments to this post.

10 comments:

Immo Hüneke said...

main.rb

# Default url mappings are:
# a controller called Main is mapped on the root of the site: /
# a controller called Something is mapped on: /something
# If you want to override this, add a line like this inside the class
# map '/otherurl'
# this will force the controller to be mounted on: /otherurl

class MainController < Ramaze::Controller
layout '/page'
DELETE_ICON = '<img src="delete_sml.gif">'
NOTDONE_ICON = '<img src="notdone_sml.gif">'
DONE_ICON = '<img src="done_sml.gif">'

# the index action is called automatically when no other action is specified
def index
@title = ["To-Do List"]
@tasks = []
TodoList.dataset.each do |task|
id = task[:id]
title = task[:title]
if task[:done]
toggle = A(DONE_ICON, :href => Rs(:open, id))
else
toggle = A(NOTDONE_ICON, :href => Rs(:close, id))
end
delete = A(DELETE_ICON, :href => Rs(:delete, id))
@tasks << [title, toggle, delete]
end
@tasks.sort!
end

def new
@title = ["Create a new To-Do List item"]
# See view/new.xhtml
end

def create
if title = request['title']
title.strip!
if title.empty?
failed("Please enter a title")
redirect '/new'
end
if TodoList.find(:title => title)
failed("Task '#{title}' already exists")
else
TodoList[nil] = {:title => title, :done => false}
end
end
end

def delete id
unless TodoList.delete id
failed "Cannot delete task no.: #{id}"
end
end

def open id
task_status id, false
end

def close id
task_status id, true
end

helper :aspect
after( :create, :delete, :open, :close ) {
redirect Rs() unless redirected?
}

# the string returned at the end of the function is used as the html body
# if there is no template for the action. if there is a template, the string
# is silently ignored
def notemplate
"there is no 'notemplate.xhtml' associated with this action"
end

private

def task_status id, status
unless task = TodoList[id]
failed "No such task no.: #{id}"
redirect_referer
end
task[:done] = status
TodoList[id] = task
end

def failed (message)
flash[:error] = message
end
end

Immo Hüneke said...

todolist.rb

require 'rubygems'
require 'sequel'

DB = Sequel.mysql('todolist_db', :user => 'ramaze', :password => 'TodoList', :host => 'localhost')

class TodoList < Sequel::Model(:tasks)
set_schema do
# You can't define a column and name it as the primary key in one go
# unless it's the default
primary_key :id
varchar :title, :unique => true, :null => false
boolean :done
end

# Copy all records into a list
def self.original
tasks = []
self.dataset.each {|r| tasks.push [r[:title], {:done => r[:done]}]}
return tasks
end

def self.delete(id)
puts "Attempting to delete '#{id}'\n"
DB.transaction do
if task = TodoList.find(:id => id)
task.destroy()
else
puts "Not found\n"
return false
end
end
end

# Assignment should update the underlying database
def self.[]=(id, values)
DB.transaction do
if (id == nil || !(task = TodoList.find(:id => id)))
task = TodoList.new
end
task.title = values[:title]
task.done = values[:done]
task.save
end
end
end

unless TodoList.table_exists?
DB.transaction do
puts "Creating table 'tasks'\n"
TodoList.create_table
end
end

Immo Hüneke said...

view/page.xhtml

<?xml version="1.0" ?>
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.1//EN"
"http://www.w3.org/TR/xhtml11/DTD/xhtml11.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
<title>#{@title}</title>
<meta http-equiv="Content-Script-Type" content="text/javascript" />
<meta http-equiv="Content-Style-Type" content="text/css" />
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<meta http-equiv="expires" content="0" />
<meta name="description" content="Description for search engines" />
<meta name="generator" content="Ramaze #{Ramaze::VERSION}" />
<meta name="keywords" content="Ramaze, Your own keywords" />
<meta name="author" content="Max Mustermann" />
<meta name="date" content="#{Time.now.iso8601}" />
<style type="text/css">
body { margin: 2em; font-family:Verdana }
#content { margin-left: 2em; }
#error { margin-left: 2em; color:red; }
</style>
<style type="text/css">
table { width: 80%; }
tr { background: #efe; width:100%; }
tr:hover { background: #dfd; }
td.title { font-weight: bold; width: 60%; }
td.status { margin: 1em; }
a { color: #3a3; }
</style>
</head>
<body>
<h1>#{@title}</h1>
<div id="error">
<p>#{flash[:error]}</p>
</div>
<div id="content">
#@content
</div>
</body>
</html>

Immo Hüneke said...

view/index.xhtml

<p><a href="/new">New Task</a></p>
<?r if @tasks.empty? ?>
<p>No Tasks</p>
<?r else ?>
<table>
<?r @tasks.each do |title, toggle, delete| ?>
<tr>
<td class="title" > #{title} </td>
<td class="toggle"> #{toggle} </td>
<td class="delete"> #{delete} </td>
</tr>
<?r end ?>
</table>
<?r end ?>

Immo Hüneke said...

view/new.xhtml

<a href="/">Back to TodoList</a>
<form method="POST" action="create">
Task: <input type="text" name="title" /><br />
<input type="submit" />
</form>

Jonas Bandi said...

Before reading your blog posts, I had never heard of Ramaze.

After you have evaluated it a bit, I would be interested in a comparison to Rails and also Merb (are there any other alternatives?)

What are the main advantages/disadvantages of each of those in your opinion?
When starting a new project, what would be your decision criteria?

(I have read the introduction in your first post, but if you would elaborate on this a bit more I would be very interested...)

Immo Hüneke said...

Hi Jonas,

Thanks for your remarks. For my part, I have never heard of Merb! Ruby on Rails was the first well-known Web application framework written in Ruby, but it has been followed by a whole slew of systems that aimed to do the same thing but better and more simply. Nitro was the first that I heard of, but if you look at Ramaze you'll find that it builds on many of the ideas in Nitro and in fact aims to be backward-compatible.

Ramaze is based on Rack, a modular web-server interface for Ruby. Several other application frameworks (also known as Adapters) are based on the same interface, as I believe Nitro was (not sure about that). The Rack homepage lists Rack::Adapter::Camping, Coset, Halcyon, Mack, Maveric, Merb, Racktools::SimpleApplication, Ramaze, Sinatra, Vintage, and Waves. No doubt very few of these will ever be mainstream.

I don't have time to evaluate all of them - the only comparison I can make is with Rails. From my limited experience with Rails, it's powerful but quite rigid - you still have to do quite a lot to get an application going. Traceability is difficult because pieces of template and control logic are all over the place, and so it's hard to change anything.

Dan North made an evaluation of Nitro and many other frameworks for rapid Web application development. He originally recommended Nitro back in April 2008, but subsequently was won over to Ramaze. Using the algorithm I also use for online shopping (find one acceptable offer, then try to better it but only once) I therefore chose Ramaze to learn.

One thing I found good about Ramaze was that it all seems to work in the way you would expect - there have not been any surprises so far. Because it's so new, the documentation can't keep up with the code, so you sometimes have to hunt around a bit to find stuff out, but the source code is very logically organised and does tell you what you need to know. The fact that Rails needs several fat books to help you get the most out of it tells its own story.

Jonas Bandi said...

Hi Immo,
thanks for you answer!

I skimmed a bit more thoroughly through your post and the tutorial itself.

I have the impression, that Ramaze offers 'just' MVC.
Compared to that RoR and Merb offer a full stack by integrating with ORM/DataAccess and Migrations.

In Ramaze you have to do this yourself. (I never heard of Sequel either).

I this impression correct?

Regards
jonas

Immo Hüneke said...

Hi Jonas,

I think your impression is correct. Ramaze is an adapter framework. If you want interfaces to web server, database server, authentication service, messaging, e-mail and so on, even a full-blown application services stack, you simply choose a package appropriate to your needs and add it in. To my mind, that is a strength and not a weakness. You don't include what you don't need.

Best regards,
Immo

Jonas Bandi said...

Hi immo,

interesting conversation, thanks.
Out of fun I am going to play a bit 'advocatus diaboli' :-)

I agree in theory that freedom of choice is always good ...

But it is a double-edged blade. You have to do the adaption to the package of choice yourself. Your first attempt is going to be suboptimal, you have to work out best practices for yourself.

Your post seems to be perfectly document this claim: The real 'brain-work' starts in chapter 12 where you have to make all kind of changes for integrating Sequel.

I think that's exactly what RoR tries to minimize. Give you some rails where you can drive on. Thats why they call it "opinionated software".