package # hide from PAUSE Catalyst::Controller::DBIC::API::Base; use strict; use warnings; use base qw/Catalyst::Controller/; use CGI::Expand qw/expand_hash/; use DBIx::Class::ResultClass::HashRefInflator; use JSON::Any; use Test::Deep::NoTest qw/eq_deeply/; __PACKAGE__->mk_accessors(qw/ class create_requires update_requires update_allows create_allows list_count list_returns list_prefetch list_prefetch_allows list_grouped_by list_search_exposes list_ordered_by rs_stash_key object_stash_key setup_list_method setup_dbic_args_method /); __PACKAGE__->config( class => undef, create_requires => [], create_allows => [], update_requires => [], update_allows => [], list_returns => [], list_prefetch => undef, list_prefetch_allows => [], list_grouped_by => [], list_search_exposes => [], list_ordered_by => [], list_count => undef, object_stash_key => 'object', rs_stash_key => 'class_rs' ); sub setup :Chained('specify.in.subclass.config') :CaptureArgs(0) :PathPart('specify.in.subclass.config') { my ($self, $c) = @_; $c->stash->{$self->rs_stash_key} = $c->model($self->class); } # from Catalyst::Action::Serialize sub deserialize :ActionClass('Deserialize') { my ($self, $c) = @_; my $req_params; if ($c->req->data && scalar(keys %{$c->req->data})) { $req_params = $c->req->data; } else { $req_params = expand_hash($c->req->params); foreach my $param (qw/search list_count list_ordered_by list_grouped_by list_prefetch/) { # these params can also be composed of JSON eval { my $deserialized = JSON::Any->from_json($req_params->{$param}); $req_params->{$param} = $deserialized; }; } } $c->stash->{_dbic_api}->{req_params} = $req_params; } sub list :Private { my ($self, $c) = @_; return if $self->get_errors($c); my $ret = $c->forward('generate_dbic_search_args'); return unless ($ret && ref $ret); my ($params, $args) = @{$ret}; return if $self->get_errors($c); $c->stash->{$self->rs_stash_key} = $c->stash->{$self->rs_stash_key}->search($params, $args); # add the total count of all rows in case of a paged resultset $c->stash->{_dbic_api}->{totalcount} = $c->stash->{$self->rs_stash_key}->pager->total_entries if $args->{page}; $c->forward('format_list'); } sub generate_dbic_search_args :Private { my ($self, $c) = @_; my $args = {}; my $req_params = $c->stash->{_dbic_api}->{req_params}; my $prefetch = $req_params->{list_prefetch} || $self->list_prefetch || undef; if ($prefetch) { $prefetch = [$prefetch] unless ref $prefetch; # validate the prefetch param against list_prefetch_allows foreach my $prefetch_allows (@{$self->list_prefetch_allows}) { if (eq_deeply($prefetch, $prefetch_allows)) { $args->{prefetch} = $prefetch; # stop looking for a valid prefetch param last; } } unless (exists $args->{prefetch}) { $self->push_error($c, { message => "prefetch validation failed" }); } } if ( my $action_name = $self->setup_list_method ) { my $setup_action = $self->action_for($action_name); if ( defined $setup_action ) { $c->forward("/$setup_action", [ $req_params ]); } else { $c->log->error("setup_list_method was configured, but action $action_name not found"); } } my $source = $c->stash->{$self->rs_stash_key}->result_source; my ($params, $join); if ($req_params->{search} && !ref $req_params->{search}) { $self->push_error($c, { message => "can not parse search arg" }); return; } ($params, $join) = $self->_format_search($c, { params => $req_params->{search}, source => $source }) if ($req_params->{search}); $args->{group_by} = $req_params->{list_grouped_by} || ((scalar(@{$self->list_grouped_by})) ? $self->list_grouped_by : undef); $args->{order_by} = $req_params->{list_ordered_by} || ((scalar(@{$self->list_ordered_by})) ? $self->list_ordered_by : undef); $args->{rows} = $req_params->{list_count} || $self->list_count; $args->{page} = $req_params->{list_page}; if ($args->{page}) { unless ($args->{page} =~ /^\d+$/xms) { $self->push_error($c, { message => "list_page must be numeric" }); } } if ($args->{rows}) { unless ($args->{rows} =~ /^\d+$/xms) { $self->push_error($c, { message => "list_count must be numeric" }); } } if ($args->{page} && !$args->{rows}) { $self->push_error($c, { message => "list_page can only be used with list_count" }); } $args->{select} = $req_params->{list_returns} || ((scalar(@{$self->list_returns})) ? $self->list_returns : undef); if ($args->{select}) { # make sure all columns have an alias to avoid ambiguous issues $args->{select} = [map { ($_ =~ m/\./) ? $_ : "me.$_" } (ref $args->{select}) ? @{$args->{select}} : $args->{select}]; } $args->{join} = $join; if ( my $action_name = $self->setup_dbic_args_method ) { my $format_action = $self->action_for($action_name); if ( defined $format_action ) { ($params, $args) = @{$c->forward("/$format_action", [ $params, $args ])}; } else { $c->log->error("setup_dbic_args_method was configured, but action $action_name not found"); } } return [$params, $args]; } sub _format_search { my ($self, $c, $p) = @_; my $params = $p->{params}; my $source = $p->{source}; my $base = $p->{base} || 'me'; my $join = {}; my %search_params; # munge list_search_exposes into format that's easy to do with my %valid = map { (ref $_) ? %{$_} : ($_ => 1) } @{$p->{_list_search_exposes} || $self->list_search_exposes}; if ($valid{'*'}) { # if the wildcard is passed they can access any column or relationship $valid{$_} = 1 for $source->columns; $valid{$_} = ['*'] for $source->relationships; } # figure out the valid cols, defaulting to all cols if not specified my @valid_cols = @{$self->list_search_exposes} ? (grep { $valid{$_} eq 1 } keys %valid) : $source->columns; # figure out the valid rels, defaulting to all rels if not specified my @valid_rels = @{$self->list_search_exposes} ? (grep { ref $valid{$_} } keys %valid) : $source->relationships; my %_col_map = map { $_ => 1 } @valid_cols; my %_rel_map = map { $_ => 1 } @valid_rels; my %_source_col_map = map { $_ => 1 } $source->columns; # validate search params foreach my $key (keys %{$params}) { # if req args is a ref, assume it refers to a rel # XXX this is broken when attempting complex search # XXX clauses on a col like { col => { LIKE => '%dfdsfs%' } } # XXX when rel and col have the same name next if $valid{'*'}; if (ref $params->{$key} && $_rel_map{$key}) { $self->push_error($c, { message => "${key} is not a valid relation" }) unless (exists $_rel_map{$key}); } else { $self->push_error($c, { message => "${key} is not a valid column" }) unless exists $_col_map{$key}; } } # build up condition on root source foreach my $column (@valid_cols) { next unless (exists $params->{$column}); next if ($_rel_map{$column} && ref $params->{$column}); if ($_source_col_map{$column}) { $search_params{join('.', $base, $column)} = $params->{$column}; } else { $search_params{$column} = $params->{$column}; } } # build up related conditions foreach my $rel (@valid_rels) { next if ($search_params{join('.', $base, $rel)}); # if it's a condition on the base source, then it's can't also be a rel next unless (exists $params->{$rel}); next unless (ref $params->{$rel}); my $rel_params; ($rel_params, $join->{$rel}) = $self->_format_search($c, { params => $params->{$rel}, source => $source->related_source($rel), base => $rel, _list_search_exposes => $valid{$rel} }); %search_params = ( %search_params, %{$rel_params} ); } return (\%search_params, $join); } sub format_list :Private { my ($self, $c) = @_; # Create another result set here, so if someone looks at $self->rs_stash_key # it still is what they expect (and not inflating to a hash ref) my $rs = $c->stash->{$self->rs_stash_key}->search; $rs->result_class('DBIx::Class::ResultClass::HashRefInflator'); $c->stash->{response}->{list} = [ $rs->all ]; if (my $totalcount = $c->stash->{_dbic_api}->{totalcount}) { # numify which is important for JSON $totalcount += 0; $c->stash->{response}->{totalcount} = $totalcount; } } sub create :Private { my ($self, $c) = @_; unless (ref($c->stash->{create_requires} || $self->create_requires) eq 'ARRAY') { die "create_requires must be an arrayref in config or stash"; } unless ($c->stash->{$self->rs_stash_key}) { die "class resultset not set"; } my $empty_object = $c->stash->{$self->rs_stash_key}->new_result({}); $c->stash->{created_object} = $self->validate_and_save_object($c, $empty_object); } sub update :Private { my ($self, $c) = @_; # expand params unless they have already been expanded my $req_params = $c->stash->{_dbic_api}->{req_params}; die "no object to update (looking at " . $self->object_stash_key . ")" unless ( defined $c->stash->{$self->object_stash_key} ); unless (ref($c->stash->{update_allows} || $self->update_allows) eq 'ARRAY') { die "update_allows must be an arrayref in config or stash"; } unless ($c->stash->{$self->rs_stash_key}) { die "class resultset not set"; } my $object = $c->stash->{$self->object_stash_key}; $self->validate_and_save_object($c, $object); } sub delete :Private { my ($self, $c) = @_; return unless ($c->stash->{$self->object_stash_key}); return $c->stash->{$self->object_stash_key}->delete; } sub validate_and_save_object { my ($self, $c, $object) = @_; my $params; unless ($params = $self->validate($c, $object)) { $c->log->debug("No value from validate, cowardly bailing out") if $c->debug; return; } if ( $c->debug ) { $c->log->debug("Saving object: $object"); $c->log->_dump( $params ); } return $self->save_object($c, $object, $params); } sub validate { my ($self, $c, $object) = @_; my $params = $c->stash->{_dbic_api}->{req_params}; my %values; my %requires_map = map { $_ => 1 } @{($object->in_storage) ? [] : $c->stash->{create_requires} || $self->create_requires}; my %allows_map = map { (ref $_) ? %{$_} : ($_ => 1) } (keys %requires_map, @{($object->in_storage) ? ($c->stash->{update_allows} || $self->update_allows) : ($c->stash->{create_allows} || $self->create_allows)}); foreach my $key (keys %allows_map) { # check value defined if key required my $allowed_fields = $allows_map{$key}; if (ref $allowed_fields) { my $related_source = $object->result_source->related_source($key); unless ($related_source) { $self->push_error($c, { message => "${key} is not a valid relation" }); next; } my $related_params = $params->{$key}; # it's an error for $c->req->params->{$key} to be defined but not be an array unless (ref $related_params) { unless (!defined $related_params) { $self->push_error($c, { message => "Value of ${key} must be a hash" }); } next; } my %allowed_related_map = map { $_ => 1 } @{$allowed_fields}; my $allowed_related_cols = ($allowed_related_map{'*'}) ? [$related_source->columns] : $allowed_fields; foreach my $related_col (@{$allowed_related_cols}) { if (my $related_col_value = $related_params->{$related_col}) { $values{$key}{$related_col} = $related_col_value; } } } else { my $value = $params->{$key}; if ($requires_map{$key}) { unless (defined($value)) { # if not defined look for default $value = $object->result_source ->column_info($key) ->{default_value}; unless (defined $value) { $self->push_error($c, { message => "No value supplied for ${key} and no default" }); } } } # TODO: do automatic col type checking here # check for multiple values if (ref($value)) { $self->push_error($c, { message => "Multiple values for '${key}'" }); } # check exists so we don't just end up with hash of undefs # check defined to account for default values being used $values{$key} = $value if exists $params->{$key} || defined $value; } } # use Data::Dumper; $c->log->debug(Dumper(\%values)); unless (keys %values || !$object->in_storage) { $self->push_error($c, { message => "No valid keys passed" }); } return ($self->get_errors($c)) ? 0 : \%values; } sub save_object { my ($self, $c, $object, $params) = @_; eval { if ($object->in_storage) { foreach my $key (keys %{$params}) { if (ref $params->{$key}) { my $related_params = delete $params->{$key}; my $row = $object->find_related($key, {} , {}); $row->update($related_params); } } $object->update($params); } else { $object->set_columns($params); $object->insert; } }; if ($@) { $c->log->error($@); # send a generic error to the client to not give out infos about # the database schema $self->push_error($c, { message => 'a database error has occured.' }); } return $object; } sub end :Private { my ($self, $c) = @_; # check for errors my $default_status; # Check for errors caught elsewhere if ( $c->res->status and $c->res->status != 200 ) { $default_status = $c->res->status; $c->stash->{response}->{success} = 'false'; } elsif ($self->get_errors($c)) { $c->stash->{response}->{messages} = $self->get_errors($c); $c->stash->{response}->{success} = 'false'; $default_status = 400; } else { $c->stash->{response}->{success} = 'true'; $default_status = 200; } delete $c->stash->{response}->{list} unless ($default_status == 200); $c->res->status( $default_status || 200 ); $c->forward('serialize'); } # from Catalyst::Action::Serialize sub serialize :ActionClass('Serialize') { my ($self, $c) = @_; } sub push_error { my ( $self, $c, $params ) = @_; push( @{$c->stash->{_dbic_crud_errors}}, $params->{message} || 'unknown error' ); } sub get_errors { my ( $self, $c, $params ) = @_; return $c->stash->{_dbic_crud_errors}; } =head1 AUTHOR Luke Saunders =cut 1;