[Catalyst] ModelView sketch

Hakim Cassimally hakim.cassimally at gmail.com
Fri Nov 13 00:28:54 GMT 2009


Hi!

I was chatting earlier with kentnl, f00li5h, mst, zamolxes, and later with
rafl, t0m about a problem I'm trying to solve.

I'll summarize, (possibly mainly to clear /my/ head a little... hope
this makes any sense):

The application I'm working on has various types of main content, for example:

    - a list of users
        - users in my team
        - users from an admin search
        - etc.
    - a list of quizzes
    - a single quiz
    - a list of questions
    - etc.

These are all basically "domain model objects", in my case DBIC objects.

Normally I'll want to display these as an HTML page, but I may want to be
able to display, for example, a CSV file, or an RSS document.  To make
things nice and generic, I want to be able to pass the DBIC objects I've
already retrieved and have them magically turned into a CSV file etc.!

But it's more complicated than that.  Perhaps a "list of users" will have
different fields if they're "users in my team" than if I'm an admin and
searched for them, for example.  This knowledge doesn't really belong:

    -  in the Model (which is a domain model, and shouldn't care about
how it's presented!)
    -  in the View (which shouldn't be hard-coded to show different
things to different controllers)
    -  in the Controller might be fine... but then we'd have to check
which view we're in in every controller, which seems wrong too.

I've understood that it's far more complicated to do this

    all(
        'correctly',
        'elegantly',
        'extensibly',
        'without being a huge pain in the arse',
        )

so I'm considering settling for all('working', 'not too much of a pain in the
arse').  But I was wondering if the following code sketch has enough of
value that anything can be saved from it?  Comments welcome!

=head2 The View

This is a view that display Comma Separated Values.
It expects

  $c->stash(
     fields => [qw/ list of field names /],
     rows   => [
        [ list, of, row, data ],
        [ list, of, row, data ],
        [ ... ],
     ],
  );

=cut

package MyApp::Web::View::Tabular;

use strict; use warnings;
use base 'Catalyst::View';

use Text::CSV_XS;

sub process {
    my ($self, $c) = @_;

    my $tabular = $c->stash->{tabular}
        or die "No tabular data!  Did you pass through an adaptor?";

    my $csv = Text::CSV_XS->new;

    my $string = join "\n", map {
          $csv->combine(@$_)
            or die "Error creating CSV " . $csv->error_input;
          $csv->string
        }
        $c->stash->{tabular}{fields},
        @{ $c->stash->{tabular}{rows} };

    $c->res->body( $string );
    $c->res->content_type( 'text/plain' );
}
1;

=head2 The Problem

...the Controller didn't generate the data above:  it just created something
like:

  $c->stash(
     items => [
        bless { ... } MyApp::Model::Something,
        bless { ... } MyApp::Model::Something,
        bless { ... } MyApp::Model::Something,
     ]
    };

So I want to convert between the two.  So...

=head2 Controller end action forwards to a ViewModel!

The following code is fugly, but the intention is that, for example:

  We're in controller 'Dashboard'
  and have objects of type 'Module'

Then we'll check these components (in order):

    MyApp::Web::Model::View::Dashboard::Module
    MyApp::Web::Model::View::Dashboard
    MyApp::Web::Model::View::Module

and dispatch to the first that exists, if any.

=cut

--- MyApp/Web/Controller/Root.pm
sub end : ActionClass('RenderView') {
     my ($self, $c) = @_;

+    $c->forward('view_model')
+        if $c->view->use_view_model;
}

+sub view_model : Private {
+    my ($self, $c) = @_;
+
+    # isn't there $c->view->**name or similar** to get this string?
+    my $view = $c->stash->{current_view} || $c->config->{default_view};
+    my $view_model_prefix = "View::${view}";
+
+    my $model_class = $c->stash->{model_class};
+
+    my $controller = do {
+        my $class = $c->action->class;
+        my $prefix = (ref $c) . '::Controller::';
+        $class=~s/^$prefix//;
+        $class;
+        };
+
+    my @possible_view_models = (
+        $model_class ?
"${view_model_prefix}::${controller}::$model_class" : (),
+        "${view_model_prefix}::${controller}",
+        $model_class ? "${view_model_prefix}::$model_class" : (),
+        );
+
+    if (my $view_model = first { $c->model($_) } @possible_view_models) {
+        $c->forward($view_model);
+    }
+ }

=head2 ModelView knows how to turn into the right data format

(Note I'm having to accept $c->stash to get and set this data)
(oh, and $c itself, because the subclass needs it below)

=cut

package MyApp::Web::Model::View::Tabular;

use Moose;
with 'Catalyst::Component::InstancePerContext';

has _stash => (
    isa => 'HashRef',
    is  => 'rw',
);

has _c => (
    isa => 'MyApp::Web',
    is  => 'rw',
);

sub build_per_context_instance {
    my ($class, $c) = @_;

    return $class->new(
        _stash => $c->stash,
        _c     => $c,
    );
}

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

    my $items = $self->_stash->{items}
        or die "No items to display";

    my $tabular = $self->get_table_definition( $self->_c );
    my @fields    = map $_->{field}, @$tabular;
    my @data_subs = map $_->{data},  @$tabular;

    my @rows = map {
        my $item = $_;
        [ map $_->($item), @data_subs ];
        } @$items;

    $self->_stash->{tabular} = {
        fields => \@fields,
        rows   => \@rows,
    };
}

sub get_table_definition {
    die "Abstract method get_table_definition called!  Please override
in subclass.";
}
1;

=head2 The Subclass then defines how the data will be transformed

=cut

package MyApp::Web::Model::View::Tabular::Dashboard;

use strict;
use parent 'MyApp::Web::Model::View::Tabular';

sub get_table_definition {
    my ($self, $c) = @_;
    return [
        { field => 'Name',
          data  => sub { shift->module->name } },
        { field => 'URL',
          data  => sub { $c->uri_for('/module/take', shift->module->id) } },
        { field => 'Description',
          data  => sub { shift->module->short_desc } },
        { field => 'Due Date',
          data  => sub { shift->due_date } },
        { field => 'Category',
          data  => sub { shift->module->category->id } },
        { field => 'Duration',
          data  => sub { shift->module->duration } },
        ];
}
1;

=head2 Querying the configuration

We mentioned $c->view->use_view_model above, so let's create it!
It seems to make sense to have this configurable, but even if we
add to config, we have no way of querying it!

So we create a role, to expose this configuration to the public.

=cut

package MyApp::Web::Role::View::QueryConfig;

use Moose::Role;

# expose parts of config that all views should expose

has use_view_model => (
    isa => 'Int',
    is  => 'ro',
);
1;

--- MyApp/Web.pm
+use MyApp::Web::Role::View::QueryConfig;

 __PACKAGE__->config(
+    'View::Tabular' => {
+        use_view_model => 1,
+    },
 );

# crack, via t0m
+# ensure that all ::View's can be queried for use_view_model
+before setup_component => sub {
+    my ($app, $comp) = @_;
+    MyApp::Web::Role::View::QueryConfig->meta->apply($comp->meta)
+        if $comp->isa('Catalyst::View');
+    };
+

 # Start the application
 __PACKAGE__->setup();

#

Cheers,
osfameron



More information about the Catalyst mailing list