Yancy: The Next Model
Doug Bell
Posted on September 6, 2021
Yancy is a content management system and application framework for the Mojolicious web framework. For the last year, I've been using it to develop Zapp, my workflow automation webapp. Over that time, it became harder and harder to organize all the code needed to manipulate Zapp's data. To solve this problem, I wrote Yancy::Model.
Mojolicious is a Model-View-Controller (MVC) web framework. The model layer is where the data manipulation happens: Reading and writing records in the database. It is also where business logic happens: Sending e-mail for transactions, or periodic data cleanup. The goal of a model layer is to provide an API on to the application's data so that it can be used not only by the web application, but by other tools as well.
Because Mojolicious does not provide its own model layer, most people usually turn to the DBIx::Class ORM. But, this has always felt too complex and heavy for my needs: By the time I know my project needs something like DBIx::Class, it's usually too late to migrate easily. I wanted something lighter and more agile that could grow from a rapidly-developed proof-of-concept app to the final, maintainable production version.
Yancy provides a generic API on to multiple database systems, which it calls Backends. Backends handle basic database operations with a common API. Using this common API, I built a lightweight class system for accessing data:
- Yancy::Model wraps the backend object and manages the classes.
- Yancy::Model::Schema provides methods to create and search a database table.
- Yancy::Model::Item represents a single row in a table and provides methods to update and delete it.
Using these basic classes provides a more fluent interface to the database than using the backend API directly. But, the power of a model layer is in writing custom code to make managing the data easy and safe. For this, Yancy::Model allows you to add your own classes for schemas (tables) and items (rows).
Writing custom model classes makes organizing your data management code easy. For example, Zapp has a table for workflows (called "plans") with a related table containing tasks ("plan_tasks") and another related table containing workflow input ("plan_inputs"). Whenever a user looks at a plan, they need to also see the plan's tasks and inputs. So, I can create a schema class that fetches this information automatically.
package Zapp::Schema::Plans;
use Mojo::Base 'Yancy::Model::Schema', -signatures;
sub get( $self, $id, %opt ) {
# I could use two JOINs here instead, but joining
# two 1:* relationships could result in a lot
# of data to fetch and discard...
my $plan = $self->SUPER::get( $id, %opt );
my $inputs_schema = $self->model->schema( "plan_inputs" );
$plan->{inputs} = $inputs_schema->list({ plan_id => $id }, { order_by => 'rank' })->{items};
my $tasks_schema = $self->model->schema( "plan_tasks" );
$plan->{tasks} = $tasks_schema->list({ plan_id => $id }, { order_by => 'task_id' })->{items};
return $plan;
}
Zapp also has a table for recording every time a plan is run, called "runs". This table also records the tasks and inputs the plan had when it was run in "run_tasks" and "run_inputs" respectively (along with some additional fields to record the status of the tasks and the user's actual input). In a way, a run is a plan. As above, when a user looks at a run, they need to also see the run's tasks and inputs. Since Yancy::Model uses plain Perl objects, I can refactor the plans class to also handle runs.
package Zapp::Schema::Plans;
use Mojo::Base 'Yancy::Model::Schema', -signatures;
has tasks_table => 'plan_tasks';
has inputs_table => 'plan_inputs';
sub get( $self, $id, %opt ) {
my $plan = $self->SUPER::get( $id, %opt );
my $inputs_schema = $self->model->schema( $self->inputs_schema );
$plan->{inputs} = $inputs_schema->list({ plan_id => $id }, { order_by => 'rank' })->{items};
my $tasks_schema = $self->model->schema( $self->tasks_schema );
$plan->{tasks} = $tasks_schema->list({ plan_id => $id }, { order_by => 'task_id' })->{items};
return $plan;
}
package Zapp::Schema::Runs;
use Mojo::Base 'Zapp::Schema::Plans', -signatures;
has tasks_table => 'run_tasks';
has inputs_table => 'run_inputs';
Now plans and runs both fetch their related data automatically. I can add similar functionality to create
, set
, list
, and delete
to make dealing with the related data seamless. Further, I can create completely custom methods like enqueue
which will add the plan (or the run) to the Minion job queue to be executed.
With Yancy::Model I can quickly build an API for my application's data. As my application develops, I can keep my data logic separate from my web frontend logic. Since they're separate, I can use my data logic in other applications and tools. This is the biggest benefit of using an MVC pattern with a framework like Mojolicious.
Posted on September 6, 2021
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.