Filtered Plugin für GetModels
authorSven Schöling <s.schoeling@linet-services.de>
Mon, 27 May 2013 17:43:26 +0000 (19:43 +0200)
committerSven Schöling <s.schoeling@linet-services.de>
Mon, 27 May 2013 17:47:21 +0000 (19:47 +0200)
SL/Controller/Helper/Filtered.pm [new file with mode: 0644]
SL/Controller/Helper/GetModels.pm
SL/Controller/Helper/Paginated.pm
SL/Controller/Helper/ParseFilter.pm
SL/DB/Helper/Filtered.pm [new file with mode: 0644]

diff --git a/SL/Controller/Helper/Filtered.pm b/SL/Controller/Helper/Filtered.pm
new file mode 100644 (file)
index 0000000..597a04e
--- /dev/null
@@ -0,0 +1,310 @@
+package SL::Controller::Helper::Filtered;
+
+use strict;
+
+use Exporter qw(import);
+use SL::Controller::Helper::ParseFilter ();
+use List::MoreUtils qw(uniq);
+our @EXPORT = qw(make_filtered get_filter_spec get_current_filter_params disable_filtering _save_current_filter_params _callback_handler_for_filtered _get_models_handler_for_filtered);
+
+use constant PRIV => '__filteredhelper_priv';
+
+my %controller_filter_spec;
+
+sub make_filtered {
+  my ($class, %specs)       = @_;
+
+  $specs{MODEL}           //=  $class->controller_name;
+  $specs{MODEL}             =~ s{ ^ SL::DB:: (?: .* :: )? }{}x;
+  $specs{FORM_PARAMS}     //= 'filter';
+  $specs{LAUNDER_TO}        = '__INPLACE__' unless exists $specs{LAUNDER_TO};
+  $specs{ONLY}            //= [];
+  $specs{ONLY}              = [ $specs{ONLY} ] if !ref $specs{ONLY};
+  $specs{ONLY_MAP}          = @{ $specs{ONLY} } ? { map { ($_ => 1) } @{ $specs{ONLY} } } : { '__ALL__' => 1 };
+
+  $controller_filter_spec{$class} = \%specs;
+
+  my %hook_params           = @{ $specs{ONLY} } ? ( only => $specs{ONLY} ) : ();
+  $class->run_before('_save_current_filter_params', %hook_params);
+
+  SL::Controller::Helper::GetModels::register_get_models_handlers(
+    $class,
+    callback   => '_callback_handler_for_filtered',
+    get_models => '_get_models_handler_for_filtered',
+    ONLY       => $specs{ONLY},
+  );
+
+  # $::lxdebug->dump(0, "CONSPEC", \%specs);
+}
+
+sub get_filter_spec {
+  my ($class_or_self) = @_;
+
+  return $controller_filter_spec{ref($class_or_self) || $class_or_self};
+}
+
+sub get_current_filter_params {
+  my ($self)   = @_;
+
+  return %{ _priv($self)->{filter_params} } if _priv($self)->{filter_params};
+
+  require Carp;
+  Carp::confess('It seems a GetModels plugin tries to access filter params before they got calculated. Make sure your make_filtered call comes first.');
+}
+
+sub _make_current_filter_params {
+  my ($self, %params)   = @_;
+
+  my $spec              = $self->get_filter_spec;
+  my $filter            = $params{filter} // _priv($self)->{filter} // {},
+  my %filter_args       = _get_filter_args($self, $spec);
+  my %parse_filter_args = (
+    class        => "SL::DB::Manager::$spec->{MODEL}",
+    with_objects => $params{with_objects},
+  );
+  my $laundered;
+  if ($spec->{LAUNDER_TO} eq '__INPLACE__') {
+
+  } elsif ($spec->{LAUNDER_TO}) {
+    $laundered = {};
+    $parse_filter_args{launder_to} = $laundered;
+  } else {
+    $parse_filter_args{no_launder} = 1;
+  }
+
+  my %calculated_params = SL::Controller::Helper::ParseFilter::parse_filter($filter, %parse_filter_args);
+
+  $calculated_params{query} = [
+    @{ $calculated_params{query} || [] },
+    @{ $filter_args{query} || [] },
+    @{ $params{query} || [] },
+  ];
+
+  $calculated_params{with_objects} = [
+    uniq
+    @{ $calculated_params{with_objects} || [] },
+    @{ $filter_args{with_objects} || [] },
+    @{ $params{with_objects} || [] },
+  ];
+
+  if ($laundered) {
+    if ($self->can($spec->{LAUNDER_TO})) {
+      $self->${\ $spec->{LAUNDER_TO} }($laundered);
+    } else {
+      $self->{$spec->{LAUNDER_TO}} = $laundered;
+    }
+  }
+
+  # $::lxdebug->dump(0, "get_current_filter_params: ", \%calculated_params);
+
+  _priv($self)->{filter_params} = \%calculated_params;
+
+  return %calculated_params;
+}
+
+sub disable_filtering {
+  my ($self)               = @_;
+  _priv($self)->{disabled} = 1;
+}
+
+#
+# private functions
+#
+
+sub _get_filter_args {
+  my ($self, $spec) = @_;
+
+  $spec ||= $self->get_filter_spec;
+
+  my %filter_args       = ref($spec->{FILTER_ARGS}) eq 'CODE' ? %{ $spec->{FILTER_ARGS}->($self) }
+                        :     $spec->{FILTER_ARGS}            ? do { my $sub = $spec->{FILTER_ARGS}; %{ $self->$sub() } }
+                        :                                       ();
+}
+
+sub _save_current_filter_params {
+  my ($self)        = @_;
+
+  return if !_is_enabled($self);
+
+  my $filter_spec = $self->get_filter_spec;
+  $self->{PRIV()}{filter} = $::form->{ $filter_spec->{FORM_PARAMS} };
+
+  # $::lxdebug->message(0, "saving current filter params to " . $self->{PRIV()}->{page} . ' / ' . $self->{PRIV()}->{per_page});
+}
+
+sub _callback_handler_for_filtered {
+  my ($self, %params) = @_;
+  my $priv            = _priv($self);
+
+  if (_is_enabled($self) && $priv->{filter}) {
+    my $filter_spec                             = $self->get_filter_spec;
+    my ($flattened) = SL::Controller::Helper::ParseFilter::flatten($priv->{filter}, undef, $filter_spec->{FORM_PARAMS});
+    %params = (%params, @$flattened);
+  }
+
+  # $::lxdebug->dump(0, "CB handler for filtered; params after flatten:", \%params);
+
+  return %params;
+}
+
+sub _get_models_handler_for_filtered {
+  my ($self, %params)    = @_;
+  my $spec               = $self->get_filter_spec;
+
+  # $::lxdebug->dump(0,  "params in get_models_for_filtered", \%params);
+
+  my %filter_params;
+  %filter_params = _make_current_filter_params($self, %params)  if _is_enabled($self);
+
+  # $::lxdebug->dump(0, "GM handler for filtered; params nach modif (is_enabled? " . _is_enabled($self) . ")", \%params);
+
+  return (%params, %filter_params);
+}
+
+sub _priv {
+  my ($self)        = @_;
+  $self->{PRIV()} ||= {};
+  return $self->{PRIV()};
+}
+
+sub _is_enabled {
+  my ($self) = @_;
+  return !_priv($self)->{disabled} && ($self->get_filter_spec->{ONLY_MAP}->{$self->action_name} || $self->get_filter_spec->{ONLY_MAP}->{'__ALL__'});
+}
+
+
+1;
+
+__END__
+
+=pod
+
+=encoding utf8
+
+=head1 NAME
+
+SL::Controller::Helper::Filtered - A helper for semi-automatic handling
+of filtered lists of database models in a controller
+
+=head1 SYNOPSIS
+
+In a controller:
+
+  use SL::Controller::Helper::GetModels;
+  use SL::Controller::Helper::Filtered;
+
+  __PACKAGE__->make_filter(
+    MODEL       => 'Part',
+    ONLY        => [ qw(list) ],
+    FORM_PARAMS => [ qw(filter) ],
+  );
+
+  sub action_list {
+    my ($self) = @_;
+
+    my $filtered_models = $self->get_models(%addition_filters);
+    $self->render('controller/list', ENTRIES => $filtered_models);
+  }
+
+
+=head1 OVERVIEW
+
+This helper module enables use of the L<SL::Controller::Helper::ParseFilter>
+methods in conjunction with the L<SL::Controller::Helper::GetModels> style of
+plugins. Additional filters can be defined in the database models and filtering
+can be reduced to a minimum of work.
+
+This plugin can be combined with L<SL::Controller::Sorted> and
+L<SL::Controller::Paginated> for filtered, sorted and paginated lists.
+
+The controller has to provive information where to look for filter information
+at compile time. This call is L<make_filtered>.
+
+The underlying functionality that enables the use of more than just
+the paginate helper is provided by the controller helper
+C<GetModels>. See the documentation for L<SL::Controller::Sorted> for
+more information on it.
+
+=head1 PACKAGE FUNCTIONS
+
+=over 4
+
+=item C<make_filtered %filter_spec>
+
+This function must be called by a controller at compile time. It is
+uesd to set the various parameters required for this helper to do its
+magic.
+
+Careful: If you want to use this in conjunction with
+L<SL:Controller::Helper::Paginated>, you need to call C<make_filtered> first,
+or the paginating will not get all the relevant information to estimate the
+number of pages correctly. To ensure this does not happen, this module will
+croak when it detects such a scenario.
+
+The hash C<%filter_spec> can include the following parameters:
+
+=over 4
+
+=item * C<MODEL>
+
+Optional. A string: the name of the Rose database model that is used
+as a default in certain cases. If this parameter is missing then it is
+derived from the controller's package (e.g. for the controller
+C<SL::Controller::BackgroundJobHistory> the C<MODEL> would default to
+C<BackgroundJobHistory>).
+
+=item * C<FORM_PARAMS>
+
+Optional. Indicates a key in E<$::form> to be used as filter.
+
+Defaults to the values C<filter> if missing.
+
+=item * C<LAUNDER_TO>
+
+Option. Indicates a target for laundered filter arguments in the controller.
+Can be set to C<undef> to disable laundering, and can be set to method named or
+hash keys of the controller. In the latter case the laundered structure will be
+put there.
+
+Defaults to inplace laundering which is not normally settable.
+
+=item * C<ONLY>
+
+Optional. An array reference containing a list of action names for
+which the paginate parameters should be saved. If missing or empty then
+all actions invoked on the controller are monitored.
+
+=back
+
+=back
+
+=head1 INSTANCE FUNCTIONS
+
+These functions are called on a controller instance.
+
+=over 4
+
+=item C<get_current_filter_params>
+
+Returns a hash to be used in manager C<get_all> calls or to be passed on to
+GetModels. Will only work if the get_models chain has been called at least
+once, because only then the full parameters can get parsed and stored. Will
+croak otherwise.
+
+=item C<disable_filtering>
+
+Disable filtering for the duration of the current action. Can be used
+when using the attribute C<ONLY> to L<make_filtered> does not
+cover all cases.
+
+=back
+
+=head1 BUGS
+
+Nothing here yet.
+
+=head1 AUTHOR
+
+Sven Schöling E<lt>s.schoeling@linet-services.deE<gt>
+
+=cut
index e353657..6b408b3 100644 (file)
@@ -48,9 +48,8 @@ sub get_callback {
 sub get_models {
   my ($self, %override_params) = @_;
 
-  my %default_params           = _run_handlers($self, 'get_models');
+  my %params                   = _run_handlers($self, 'get_models', %override_params);
 
-  my %params                   = (%default_params, %override_params);
   my $model                    = delete($params{model}) || die "No 'model' to work on";
 
   return "SL::DB::Manager::${model}"->get_all(%params);
index 15997b8..8d572ec 100644 (file)
@@ -18,6 +18,7 @@ sub make_paginated {
   $specs{MODEL}             =~ s{ ^ SL::DB:: (?: .* :: )? }{}x;
   $specs{PER_PAGE}        ||= "SL::DB::Manager::$specs{MODEL}"->default_objects_per_page;
   $specs{FORM_PARAMS}     ||= [ qw(page per_page) ];
+  $specs{PAGINATE_ARGS}   ||= '__FILTER__';
   $specs{ONLY}            ||= [];
   $specs{ONLY}              = [ $specs{ONLY} ] if !ref $specs{ONLY};
   $specs{ONLY_MAP}          = @{ $specs{ONLY} } ? { map { ($_ => 1) } @{ $specs{ONLY} } } : { '__ALL__' => 1 };
@@ -58,6 +59,7 @@ sub get_current_paginate_params {
   );
 
   my %paginate_args     = ref($spec->{PAGINATE_ARGS}) eq 'CODE' ? %{ $spec->{PAGINATE_ARGS}->($self) }
+                        :     $spec->{PAGINATE_ARGS}  eq '__FILTER__' ? $self->get_current_filter_params
                         :     $spec->{PAGINATE_ARGS}            ? do { my $sub = $spec->{PAGINATE_ARGS}; %{ $self->$sub() } }
                         :                                         ();
   my $calculated_params = "SL::DB::Manager::$spec->{MODEL}"->paginate(%paginate_params, args => \%paginate_args);
index ea4eba0..ee5a740 100644 (file)
@@ -8,6 +8,7 @@ our @EXPORT = qw(parse_filter);
 use DateTime;
 use SL::Helper::DateTime;
 use List::MoreUtils qw(uniq);
+use SL::MoreCommon qw(listify);
 use Data::Dumper;
 
 my %filters = (
@@ -149,7 +150,7 @@ sub _add_uniq {
    my ($array, $what) = @_;
 
    $array //= [];
-   $array = [ uniq @$array, $what ];
+   $array = [ uniq @$array, listify($what) ];
 }
 
 sub _collapse_indirect_filters {
diff --git a/SL/DB/Helper/Filtered.pm b/SL/DB/Helper/Filtered.pm
new file mode 100644 (file)
index 0000000..15e0985
--- /dev/null
@@ -0,0 +1,186 @@
+package SL::DB::Helper::Filtered;
+
+use strict;
+use SL::Controller::Helper::ParseFilter ();
+
+require Exporter;
+our @ISA    = qw(Exporter);
+our @EXPORT = qw (filter add_filter_specs);
+
+my %filter_spec;
+
+sub filter {
+  my ($class, $key, $value, $prefix) = @_;
+
+  my $filters = _get_filters($class);
+
+  return ($key, $value) unless $filters->{$key};
+
+  return $filters->{$key}->($key, $value, $prefix);
+}
+
+sub _get_filters {
+  my ($class) = @_;
+  return $filter_spec{$class} ||= {};
+}
+
+sub add_filter_specs {
+  my $class = shift;
+
+  my $filters = _get_filters($class);
+
+  while (@_ > 1) {
+    my $key          = shift;
+    $filters->{$key} = shift;
+  }
+}
+
+1;
+
+__END__
+
+=encoding utf-8
+
+=head1 NAME
+
+SL::Helper::Sorted - Manager mixin for filtered results.
+
+=head1 SYNOPSIS
+
+In the manager:
+
+  use SL::Helper::Filtered;
+
+  __PACKAGE__->add_filter_specs(
+    custom_filter_name => sub {
+      my ($key, $value, $prefix) = @_;
+      # code to handle this
+      return ($key, $value, $with_objects);
+    },
+    another_filter_name => \&_sub_to_handle_this,
+  );
+
+In consuming code:
+
+  ($key, $value, $with_objects) = $manager_class->filter($key, $value, $prefix);
+
+=head1 FUNCTIONS
+
+=over 4
+
+=item C<add_filter_specs %PARAMS>
+
+Adds new filters to this package as key value pairs. The key will be the new
+filters name, the value is expected to be a coderef to an implementation of
+this filter. See L<INTERFACE OF A CUSTOM FILTER> for details on this.
+
+You can add multiple filters in one call, but only one filter per key.
+
+=item C<filter $key, $value, $prefix>
+
+Tells the manager to pply custom filters. If none is registered for C<$key>,
+returns C<$key, $value>.
+
+Otherwise the filter code is called.
+
+=back
+
+=head1 INTERFACE OF A CUSTOM FILTER
+
+Lets look at an example of a working filter. Suppose your model has a lot of
+notes fields, and you need to search in all of them. A working filter would be:
+
+  __PACKAGE__->add_filter_specs(
+    all_notes => sub {
+      my ($key, $value, $prefix) = @_;
+
+      return or => [
+        $prefix . notes1 => $value,
+        $prefix . notes2 => $value,
+      ];
+    }
+  );
+
+If someone filters for C<filter.model.all_notes:substr::ilike=telephone>, your
+filter will get called with:
+
+  ->filter('all_notes', { ilike => '%telephone%' }, '')
+
+and the result will be:
+
+  or => [
+    notes1 => { notes1 => '%telephone%' },
+    notes2 => { notes1 => '%telephone%' },
+  ]
+
+The prefix is to make sure this also works when called on submodels:
+
+  C<filter.customer.model.all_notes:substr::ilike=telephone>
+
+will pass C<customer.> as prefix so that the resulting query will be:
+
+  or => [
+    customer.notes1 => { notes1 => '%telephone%' },
+    customer.notes2 => { notes1 => '%telephone%' },
+  ]
+
+which is pretty much what you would expect.
+
+As a final touch consider a filter that needs to search somewhere else to work,
+like this one:
+
+  __PACKAGE__->add_filter_specs(
+    name => sub {
+      my ($key, $value, $prefix) = @_;
+
+      return $prefix . person.name => $value,
+             $prefix . 'person';
+    },
+  };
+
+Now you can search for C<name> in your model without ever knowing that the real
+name lies in the table C<person>. Unfortunately Rose has to know about it to
+get the joins right, and so you need to tell it to include C<person> into its
+C<with_objects>. That's the reason for the third return value.
+
+
+To summarize:
+
+=over 4
+
+=item *
+
+You will get passed the name of your filter as C<$key> stripped of all filters
+and escapes.
+
+=item *
+
+You will get passed the C<$value> processed with all filters and escapes.
+
+=item *
+
+You will get passed a C<$prefix> that can be prepended to all database columns
+to make sense to Rose.
+
+=item *
+
+You are expeceted to return exactly one key and one value. That can mean you
+have to encapsulate your arguments into C<< or => [] >> or C<< and => [] >> blocks.
+
+=item *
+
+If your filter needs relationships that are not always loaded, you need to
+return them in C<with_objects> style. If you need to return more than one, use
+an arrayref.
+
+=back
+
+=head1 BUGS
+
+None yet.
+
+=head1 AUTHOR
+
+Sven Schöling E<lt>s.schoeling@linet-services.deE<gt>
+
+=cut