perlmoosemoosex-types

Perl Moose Dynamic assign the value to attribute suggestion


I am trying to accomplish the following.

Is there a better way to do this (so that I can pass the metadata to package A and in package B avoid calling new multiple times) also trying to get it done 1 liner if possible.

package A {

    use Moose;
    has 'metadata' => (
        is      => 'rw',
        isa     => 'HashRef',
        default => sub {{}},
        required => 1
    );

    sub process {
        die unless keys %{shift->metadata};
        # ... process
        print "Success!\n";
    }

    __PACKAGE__->meta->make_immutable;

}
#######B#########
package B {
    use Moose;
    use A;

    has 'obj_a' => (
        is      => 'rw',
        isa     => 'A',
        writer  => 'set_meta',
    );

    sub _set_meta {
        my ( $self, $metadata) = @_;
        return $self->set_meta(A->new(metadata => $metadata));
    }

    sub obj_with_meta {
        my ( $self, $metadata) = @_;
        return A->new(metadata => $metadata);
    }

    __PACKAGE__->meta->make_immutable;

    1;
}

############
use B;
my $b = B->new();

# want to call like this but I am sure I am missing something which moose is providing
# here I am supposed to call obj_a instead of _set_meta I believe
#calling _set_meta I am bypassing the Moose attribute I guess
$b->_set_meta({id=>'id for metadata'})->process;

#works
$b->obj_with_meta({id=>'id for metadata'})->process;

Note above code is working output is Success! Success!

I am trying to know if there is anything in moose that I can leverage. so that I can share data to the next class by writing to meta may be or using some trait maybe.

package A is the catalyst controller package B is an independent module not tightly coupled with the catalyst.


Solution

  • Separating business logic from your controllers in a Catalyst app is a great idea. You can encapsulate it into its own modules and use them via a thin Catalyst::Model layer.

    You don't actually need to worry about passing the session in from the controller, because all Catalyst::Components provide you with a means to do this, called ACCEPT_CONTEXT. This is a method that you can implement in any component, but typically it's used in models. It is called whenever a $c->model(...) call is done, and it gets passed the context object $c, and is supposed to return an object that can be used like a model. This might or might not be a Catalyst::Component object.

    I've build a sample application that I will be using for this answer. You can find the full source code in this github repository.

    Let's assume there is a Catalyst::Model class called MyApp::Model::API::User, with the following code. It inherits from Catalyst::Model::DBI in order to leverage database handle caching via Catalyst.

    package MyApp::Model::API::User;
    
    use strict;
    use warnings;
    
    use API::User;
    
    use parent 'Catalyst::Model::DBI';
    
    sub ACCEPT_CONTEXT {
        my ( $self, $c, @args ) = @_;
    
        $c->log->debug( sprintf 'Creating a new API::User object for %s line %d',
            ( caller(2) )[ 0, 2 ] );
    
        return API::User->new(
            dbh      => $self->dbh,
            metadata => $c->session->{data},
        );
    }
    
    1;
    

    Every time a Controller does $c->model('API::User') the ACCEPT_CONTEXT method gets called, and it instantiates a class called API::User, which is my implementation of your Catalyst-agnostic business logic. It accepts a database handle object, which the DBI Model provides for us, as well as the metadata, which we take from the user's session.

    In my example I've made the user's ID part of the session so that there is actual metadata to play with (and if there is none, we create one, but that's not important here).

    package API::User;
    
    use Moose;
    use DBI;
    
    has metadata => (
        isa      => 'HashRef',
        is       => 'ro',
        required => 1,           # either it's required or it has a default
    );
    
    has dbh => (
        isa      => 'DBI::db',
        is       => 'ro',
        required => 1,
    );
    
    sub create { ... }
    
    sub read {
        my ($self) = @_;
    
        my $sql = 'SELECT id, number_of_writes FROM user WHERE id=?';
        my $sth = $self->dbh->prepare($sql);
    
        $sth->execute( $self->metadata->{id} );
    
        return $sth->fetchrow_hashref;
    }
    
    sub write { ... }
    
    __PACKAGE__->meta->make_immutable;
    

    The API::User has three methods. It can create, read and write. This is all very much simplified as an example. We will focus on reading in this answer. Note how the metadata property is required, but has no default. You can't have both, because they contradict each other. You want this to be passed in, so you want it to blow up if it's missing, rather than set a default value of an empty hash reference.

    Finally, in a Controller this is used as follows.

    package MyApp::Controller::User;
    use Moose;
    use namespace::autoclean;
    
    BEGIN { extends 'Catalyst::Controller' }
    
    __PACKAGE__->config( namespace => 'user' );
    
    sub auto : Private {
        my ( $self, $c ) = @_;
    
        unless ( $c->session->{data}->{id} ) {
            # we have to initialise data first because the model depends on it
            $c->session->{data} = {}; 
            $c->session->{data}->{id} = $c->model('API::User')->create;
        }
        return 1;
    }
    
    sub index_get : Path('') Args(0) GET {
        my ( $self, $c ) = @_;
    
        $c->stash->{json_data} = $c->model('API::User')->read;
    
        return;
    }
    
    sub index_post : Path('') Args(0) POST {
        my ( $self, $c ) = @_;
    
        $c->stash->{json_data} = $c->model('API::User')->write;
    
        return;
    }
    
    __PACKAGE__->meta->make_immutable;
    

    I'm setting some session data in the auto action, which gets called before any other action. For a specific session this will be done once, and then that user's ID is stored in the session for subsequent requests.

    In the index_get action I am accessing our class via $c->model('API::User), which will call ACCEPT_CONTEXT on our Model class, instantiate a new API::User object that is populated with both the existing database handle as well as the session metadata that contains our user's ID.

    For the sake of the example, I'm using a JSON view so we can see what's happening in the DB.

    When we curl the application to GET our user, the logs look as follows.

    [info] *** Request 2 (0.044/s) [31642] [Fri May  6 19:01:25 2022] ***
    [debug] Path is "user"
    [debug] "GET" request for "user" from "127.0.0.1"
    [debug] Created session "36d509c55d60c02a7a0a9cbddfae9e50b092865a"
    [debug] Creating a new API::User object for MyApp::Controller::User line 15
    [debug] Creating a new API::User object for MyApp::Controller::User line 23
    [debug] Response Code: 200; Content-Type: application/json; charset=utf-8; Content-Length: unknown
    [info] Request took 0.018616s (53.717/s)
    .------------------------------------------------------------+-----------.
    | Action                                                     | Time      |
    +------------------------------------------------------------+-----------+
    | /user/auto                                                 | 0.013309s |
    | /user/index_get                                            | 0.000640s |
    | /end                                                       | 0.000994s |
    |  -> MyApp::View::JSON->process                             | 0.000411s |
    '------------------------------------------------------------+-----------'
    

    As you can see, we go to auto first, and then go to index_get. In the debug statements above it creates two instances of API::User. One is in auto to create a new user because I've not supplied a session cookie, and the second is from index_get.

    If we call it with an existing user by supplying a session cookie (see my test script in the repository) it will only call it once.

    [info] *** Request 8 (0.037/s) [31642] [Fri May  6 19:04:16 2022] ***
    [debug] Path is "user"
    [debug] "GET" request for "user" from "127.0.0.1"
    [debug] Found sessionid "710cb37124a7042b89f1ffa650985956949df7d0" in cookie
    [debug] Restored session "710cb37124a7042b89f1ffa650985956949df7d0"
    [debug] Creating a new API::User object for MyApp::Controller::User line 23
    [debug] Response Code: 200; Content-Type: application/json; charset=utf-8; Content-Length: unknown
    [info] Request took 0.017655s (56.641/s)
    .------------------------------------------------------------+-----------.
    | Action                                                     | Time      |
    +------------------------------------------------------------+-----------+
    | /user/auto                                                 | 0.001887s |
    | /user/index_get                                            | 0.001238s |
    | /end                                                       | 0.003510s |
    |  -> MyApp::View::JSON->process                             | 0.001463s |
    '------------------------------------------------------------+-----------'