Build MVC application using Catalyst and DBIx::Class

Having played with Dancer and Rose::DB earlier and blogged about it here, I thought why not play with Catalyst and DBIx::Class now. If you are interested in my earlier blog series then here is [Part 1] and [Part 2].

Source code for this blog are here.

Lets get our hand dirty now. Before we begin lets get the environment sorted first. The sample code has been tested on Windows Vista Home Premium Edition (32 bit) machine with the following modules:

  • perl 5, version 16, subversion 0 (v5.16.0)
  • Catalyst v5.90015
  • Catalyst::Devel v1.37
  • DBIx::Class v0.08198
  • Catalyst::Model::DBIC::Schema v0.6
  • HTML::FormFu v0.09007
  • DBD::SQLite v1.37
  • Template Toolkit v2.24
  • SQLite v3

[STEP 1]: We will create the skeleton of our catalyst application like below:

C:\>catalyst JobBulletin
created "JobBulletin"
created "JobBulletin\script"
created "JobBulletin\lib"
created "JobBulletin\root"
created "JobBulletin\root\static"
created "JobBulletin\root\static\images"
created "JobBulletin\t"
created "JobBulletin\lib\JobBulletin"
created "JobBulletin\lib\JobBulletin\Model"
created "JobBulletin\lib\JobBulletin\View"
created "JobBulletin\lib\JobBulletin\Controller"
created "JobBulletin\jobbulletin.conf"
created "JobBulletin\jobbulletin.psgi"
created "JobBulletin\lib\JobBulletin.pm"
created "JobBulletin\lib\JobBulletin\Controller\Root.pm"
created "JobBulletin\README"
created "JobBulletin\Changes"
created "JobBulletin\t\01app.t"
created "JobBulletin\t\02pod.t"
created "JobBulletin\t\03podcoverage.t"
created "JobBulletin\root\static\images\catalyst_logo.png"
created "JobBulletin\root\static\images\btn_120x50_built.png"
created "JobBulletin\root\static\images\btn_120x50_built_shadow.png"
created "JobBulletin\root\static\images\btn_120x50_powered.png"
created "JobBulletin\root\static\images\btn_120x50_powered_shadow.png"
created "JobBulletin\root\static\images\btn_88x31_built.png"
created "JobBulletin\root\static\images\btn_88x31_built_shadow.png"
created "JobBulletin\root\static\images\btn_88x31_powered.png"
created "JobBulletin\root\static\images\btn_88x31_powered_shadow.png"
created "JobBulletin\root\favicon.ico"
created "JobBulletin\Makefile.PL"
created "JobBulletin\script\jobbulletin_cgi.pl"
created "JobBulletin\script\jobbulletin_fastcgi.pl"
created "JobBulletin\script\jobbulletin_server.pl"
created "JobBulletin\script\jobbulletin_test.pl"
created "JobBulletin\script\jobbulletin_create.pl"
Change to application directory and Run "perl Makefile.PL" to make sure your install is complete

C:\> cd JobBulletin
C:\JobBulletin> perl Makefile.PL


[STEP 2]:Now we are going create sample data for our catalyst application. For this we will create a sql script jobbulletin.sql in the folder C:\JobBulletin.

--
-- Create a very simple database to hold job and job category information
--
PRAGMA foreign_keys = ON;
CREATE TABLE job (
id INTEGER PRIMARY KEY,
title TEXT,
created TIMESTAMP,
updated TIMESTAMP
);
-- 'job_category' is a many-to-many join table between job & category
CREATE TABLE job_category (
job_id INTEGER
REFERENCES job(id) ON DELETE CASCADE ON UPDATE CASCADE,
category_id INTEGER
REFERENCES category(id) ON DELETE CASCADE ON UPDATE CASCADE,
PRIMARY KEY (job_id, category_id)
);
CREATE TABLE category (
id INTEGER PRIMARY KEY,
title TEXT,
description TEXT,
created TIMESTAMP,
updated TIMESTAMP
);
---
--- Load some sample data
---
INSERT INTO job VALUES(1,'Perl Developer',DATETIME('NOW'),DATETIME('NOW'));
INSERT INTO job VALUES(2,'Software Engineer',DATETIME('NOW'),DATETIME('NOW'));
INSERT INTO job VALUES(3,'Bank Manager',DATETIME('NOW'),DATETIME('NOW'));
INSERT INTO category VALUES(1,'IT','Information Technology',DATETIME('NOW'),DATETIME('NOW'));
INSERT INTO category VALUES(2,'Banking','Banking',DATETIME('NOW'),DATETIME('NOW'));
INSERT INTO job_category VALUES (1, 1);
INSERT INTO job_category VALUES (2, 1);
INSERT INTO job_category VALUES (3, 2);

It is time to create sqlite database using the sql script we just created.

C:\JobBulletin> sqlite3 jobbulletin.db < jobbulletin.sql

[STEP 3]: Lets create the folders we would need very soon.

C:\JobBulletin> cd root
C:\JobBulletin\root> cd static
C:\JobBulletin\root\static> mkdir css
C:\JobBulletin\root\static> cd ..
C:\JobBulletin\root> mkdir forms
C:\JobBulletin\root> cd forms
C:\JobBulletin\root\forms> mkdir jobs
C:\JobBulletin\root\forms> cd ..
C:\JobBulletin\root> mkdir src
C:\JobBulletin\root> cd src
C:\JobBulletin\root\src> mkdir jobs
C:\JobBulletin\root\src> cd ..\..
C:\JobBulletin>

[STEP 4]: Lets edit the Makefile.PL in the folder C:\JobBulletin and add the following lines:

requires 'Catalyst::Plugin::StackTrace';
requires 'HTML::FormFu';
requires 'Catalyst::Controller::HTML::FormFu';
requires 'HTML::FormFu::Model::DBIC';

[STEP 5]: Now we are going create a very simple css (main.css) for our catalyst application in the folder C:\JobBulletin\root\static\css.

#header {
text-align: center;
}
#header h1 {
margin: 0;
}
#header img {
float: right;
}
#footer {
text-align: center;
font-style: italic;
padding-top: 20px;
}
#menu {
font-weight: bold;
background-color: #ddd;
}
#menu ul {
list-style: none;
float: left;
margin: 0;
padding: 0 0 50% 5px;
font-weight: normal;
background-color: #ddd;
width: 120px;
}
#content {
margin-left: 120px;
}
.message {
color: #390;
}
.error {
color: #f00;
}
input {
display: block;
}
select {
display: block;
}
.submit {
padding-top: .5em;
display: block;
}

[STEP 6]: Now we are going to create configuration file "formfu_create.yml" for "Create Job" form in the folder C:\JobBulletin\root\forms\jobs.

---
indicator: submit
elements:
- type: Text
name: title
label: Title
attributes:
title: Enter a job title here
constraints:
- type: Length
min: 5
max: 40
message: Length must be between 5 and 40 characters
- type: Select
name: categories
label: Category
multiple: 1
size: 3
constraints:
- Integer
- type: Submit
name: submit
value: Submit
constraints:
- Required
filter:
- TrimEdges
- HTMLEscape

[STEP 7]: Time to create template "formfu_create.tt2" for "Create Job" form in the folder C:\JobBulletin\root\src\jobs.

[% META title = 'Create/Update Job' %]
[% form %]

[STEP 8]: Next is to create template "list.tt2" for "Job List" in the folder
C:\JobBulletin\root\src\jobs.


[% META title = 'Job List' -%]
<table>
<tr><th>Title</th><th>Category</th><th>Links</th></tr>
[% FOREACH job IN jobs -%]
<tr>
<td>[% job.title %]</td>
<td>[% job.category | html %]</td>
<td>
<a href="[% c.uri_for(c.controller.action_for('delete'), [job.id]) %]">Delete</a>
</td>
<td>
<a href="[% c.uri_for(c.controller.action_for('formfu_edit'),[job.id]) %]">Edit</a>
</td>
</tr>
[% END -%]
</table>


[STEP 9]: Finally a warpper template "wrapper.tt2" for consistent look and feel in the folder C:\JobBulletin\root\src.


<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" [%#
%]"http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
<head>
<title>[% template.title or "My Job Bulletin" %]</title>
<link rel="stylesheet" href="[% c.uri_for('/static/css/main.css') %]" />
</head>
<body>
<div id="outer">
<div id="header">
<img src="[% c.uri_for('/static/images/btn_88x31_powered.png') %]" />
<h1>[% template.title or site.title %]</h1>
</div>
<div id="bodyblock">
<div id="menu">
Navigation:
<ul>
<li>
[ <a href="[% c.uri_for('/') %]">Welcome</a> ]
</li>
<li>
[ <a href="[% c.uri_for('/jobs/list') %]">Job List</a> ]
</li>
<li>[ <a href="[% c.uri_for(c.controller.action_for('formfu_create')) %]">
Add new job</a> ]</li>
</ul>
</div><!-- end menu -->
<div id="content">
<span class="message">
[% status_msg || c.request.params.status_msg %]</span>
<span class="error">[% error_msg %]
</span>
[% content %]
</div><!-- end content -->
</div><!-- end bodyblock -->
<div id="footer">Copyright (c) Mohammad S Anwar 2012</div>
</div><!-- end outer -->
</body>
</html>


[STEP 10]: Now we are going to do the fun stuff. We will first edit JobBulletin.pm in the folder C:\JobBulletin\lib and add "StackTrace" to the list of plugins like below:

use Catalyst qw/
-Debug
ConfigLoader
Static::Simple

StackTrace
/;


Then add the following method call:

__PACKAGE__->config(
'View::HTML' => {
INCLUDE_PATH => [
__PACKAGE__->path_to('root', 'src'),
],
TIMER => 0,
WRAPPER => 'wrapper.tt2',
},
);

[STEP 11]: Lets create the "View" of MVC for our catalyst application:

C:\JobBulletin>perl script/jobbulletin_create.pl view HTML TT
exists "C:\JobBulletin\lib\JobBulletin\View"
exists "C:\JobBulletin\t"
created "C:\JobBulletin\lib\JobBulletin\View\HTML.pm"
created "C:\JobBulletin\t\view_HTML.t"

[STEP 12]: Lets create the "Controller" of MVC for our catalyst application:

C:\JobBulletin>perl script/jobbulletin_create.pl controller Jobs
exists "C:\JobBulletin\lib\JobBulletin\Controller"
exists "C:\JobBulletin\t"
created "C:\JobBulletin\lib\JobBulletin\Controller\Jobs.pm"
created "C:\JobBulletin\t\controller_Jobs.t"

[STEP 13]: Now we are going to edit the HTML.pm in the
folder C:\JobBulletin\lib\JobBulletin\View.
  • Change the TEMPLATE_EXTENSION value to '.tt2'
  • Add the following three properties in the call to config()


INCLUDE_PATH => [ JobBulletin->path_to('root', 'src') ],
TIMER => 0,
WRAPPER => 'wrapper.tt2',

[STEP 14]: Lets edit the Jobs.pm in the folder C:\JobBulletin\lib\JobBulletin\Controller.
  • Replace extends 'Catalyst::Controller' with
    extends 'Catalyst::Controller::HTML::FormFu'.
  • Add the following method list(), base(), object(), delete(), formfu_create() and formfu_edit()


=head2 list
Fetch all job objects and pass to jobs/list.tt2 in stash to be displayed
=cut

sub list :Local {
my ($self, $c) = @_;
$c->stash(jobs => [$c->model('DB::Job')->all]);
$c->stash(template => 'jobs/list.tt2');
}

=head2 base
Can place common logic to start chained dispatch here
=cut

sub base :Chained('/') :PathPart('jobs') :CaptureArgs(0) {
my ($self, $c) = @_;
$c->stash(resultset => $c->model('DB::Job'));
}

=head2 object
Fetch the specified job object based on the job id and store it in the stash
=cut

sub object :Chained('base') :PathPart('id') :CaptureArgs(1) {
my ($self, $c, $id) = @_;
$c->stash(object => $c->stash->{resultset}->find($id));
die "Job $id not found!" if !$c->stash->{object};
}

=head2 delete
Delete a job
=cut

sub delete :Chained('object') :PathPart('delete') :Args(0) {
my ($self, $c) = @_;
$c->stash->{object}->delete;
$c->response->redirect($c->uri_for($self->action_for('list'),
{status_msg => "Job deleted."}));
}

=head2 formfu_create
Use HTML::FormFu to create a new job
=cut

sub formfu_create :Chained('base')
:PathPart('formfu_create') :Args(0) :FormConfig {
my ($self, $c) = @_;

my $form = $c->stash->{form};
if ($form->submitted_and_valid) {
my $job = $c->model('DB::Job')->new_result({});
$form->model->update($job);
$c->response->redirect($c->uri_for($self->action_for('list'),
{status_msg => "Job created."}));
$c->detach;
} else {
my @category_objs = $c->model("DB::Category")->all();
my @categories;
foreach (sort {$a->title cmp $b->title} @category_objs) {
push(@categories, [$_->id, $_->title]);
}
my $select = $form->get_element({type => 'Select'});
$select->options(\@categories);
}
$c->stash(template => 'jobs/formfu_create.tt2');
}

=head2 formfu_edit
Use HTML::FormFu to update an existing job
=cut

sub formfu_edit :Chained('object') :PathPart('formfu_edit') :Args(0)
:FormConfig('jobs/formfu_create.yml') {
my ($self, $c) = @_;

my $job = $c->stash->{object};
unless ($job) {
$c->response->redirect($c->uri_for($self->action_for('list'),
{error_msg => "Invalid job -- cannot edit."}));
$c->detach;
}

my $form = $c->stash->{form};
if ($form->submitted_and_valid) {
$form->model->update($job);
$c->response->redirect($c->uri_for($self->action_for('list'),
{ status_msg => "Job edited"}));
$c->detach;
} else {
my @category_objs = $c->model("DB::Category")->all();
my @categories;
foreach (sort {$a->title cmp $b->title} @category_objs) {
push(@categories, [$_->id, $_->title]);
}
my $select = $form->get_element({type => 'Select'});
$select->options(\@categories);
$form->model->default_values($job);
}
$c->stash(template => 'jobs/formfu_create.tt2');
}


[STEP 15]: Lets create the "Model" of MVC for our catalyst application:


C:\JobBulletin>perl script/jobbulletin_create.pl model DB DBIC::Schema JobBulletin::Schema create=static components=TimeStamp dbi:SQLite:jobbulletin.db on_connect_do="PRAGMA foreign_keys = ON"
exists "C:\JobBulletin\lib\JobBulletin\Model"
exists "C:\JobBulletin\t"
Dumping manual schema for JobBulletin::Schema to directory C:\JobBulletin\lib ...
Schema dump completed.
exists "C:\JobBulletin\lib\JobBulletin\Model\DB.pm"

[STEP 16]: Add the following code in the "Job.pm" just after the line
# DO NOT MODIFY THIS OR ANYTHING ABOVE!
in the folder C:\JobBulletin\Schema\Result.


#
# Enable automatic date handling
#
__PACKAGE__->add_columns(
"created",
{ data_type => 'timestamp', set_on_create => 1 },
"updated",
{ data_type => 'timestamp', set_on_create => 1, set_on_update => 1 },
);

=head2 category
Return a comma-separated list of category for the current job
=cut

sub category {
my ($self) = @_;

my @categories;
foreach my $category ($self->categories) {
push(@categories, $category->title);
}
return join(', ', @categories);
}


[STEP 17]: Start the development server

C:\JobBulletin>perl script\jobbulletin_server.pl -r -d
[debug] Debug messages enabled
[debug] Statistics enabled
[debug] Loaded plugins:
.----------------------------------------------------------------------------.
| Catalyst::Plugin::ConfigLoader 0.30 |
| Catalyst::Plugin::StackTrace 0.11 |
'----------------------------------------------------------------------------'

[debug] Loaded dispatcher "Catalyst::Dispatcher"
[debug] Loaded engine "Catalyst::Engine"
[debug] Found home "C:\JobBulletin"
[debug] Loaded Config "C:\JobBulletin\jobbulletin.conf"
[debug] Loaded components:
.-----------------------------------------------------------------+----------.
| Class | Type |
+-----------------------------------------------------------------+----------+
| JobBulletin::Controller::Jobs | instance |
| JobBulletin::Controller::Root | instance |
| JobBulletin::Model::DB | instance |
| JobBulletin::Model::DB::Category | class |
| JobBulletin::Model::DB::Job | class |
| JobBulletin::Model::DB::JobCategory | class |
| JobBulletin::View::HTML | instance |
'-----------------------------------------------------------------+----------'
[debug] Loaded Private actions:
.----------------------+--------------------------------------+---------------.
| Private | Class | Method |
+----------------------+--------------------------------------+---------------+
| /default | JobBulletin::Controller::Root | default |
| /end | JobBulletin::Controller::Root | end |
| /index | JobBulletin::Controller::Root | index |
| /jobs/base | JobBulletin::Controller::Jobs | base |
| /jobs/object | JobBulletin::Controller::Jobs | object |
| /jobs/formfu_edit | JobBulletin::Controller::Jobs | formfu_edit |
| /jobs/index | JobBulletin::Controller::Jobs | index |
| /jobs/delete | JobBulletin::Controller::Jobs | delete |
| /jobs/formfu_create | JobBulletin::Controller::Jobs | formfu_create |
| /jobs/list | JobBulletin::Controller::Jobs | list |
'----------------------+--------------------------------------+---------------'
[debug] Loaded Path actions:
.-------------------------------------+--------------------------------------.
| Path | Private |
+-------------------------------------+--------------------------------------+
| / | /index |
| /... | /default |
| /jobs/ | /jobs/index |
| /jobs/list/... | /jobs/list |
'-------------------------------------+--------------------------------------'
[debug] Loaded Chained actions:
.-------------------------------------+--------------------------------------.
| Path Spec | Private |
+-------------------------------------+--------------------------------------+
| /jobs/id/*/delete | /jobs/base (0) |
| | -> /jobs/object (1) |
| | => /jobs/delete |
| /jobs/formfu_create | /jobs/base (0) |
| | => /jobs/formfu_create |
| /jobs/id/*/formfu_edit | /jobs/base (0) |
| | -> /jobs/object (1) |
| | => /jobs/formfu_edit |
'-------------------------------------+--------------------------------------'
[info] JobBulletin powered by Catalyst 5.90015
HTTP::Server::PSGI: Accepting connections at http://0:3000/


[STEP 18]: Time to play the game now. Point your favourite browser to this url http://localhost:3000/jobs/list

Thats it for now but very soon I will add the Authentication and Authorization to this application. Any suggestion or correction most welcome.

Have fun with Catalyst and DBIx::Class.

Leave a comment

About Mohammad S Anwar

user-pic CPAN Contributor. Co-editor of Perl Weekly newsletter. Run Perl Weekly Challenge @PerlWChallenge. Father of 3 angels. Indian by birth, British by choice.