use warnings; use strict; =head1 NAME Jifty::Action::Record::Search - Automagic search action =head1 DESCRIPTION The class is a base class for Ls that serve to provide an interface to general searches through L objects. To use it, subclass it and override the C method to return the fully qualified name of the model to do searches over. =cut package Jifty::Action::Record::Search; use base qw/Jifty::Action::Record/; =head1 METHODS =head2 arguments Remove validators from arguments, as well as ``mandatory'' restrictions. Remove any arguments that render as password fields, or refer to collections. Generate additional search arguments for each field based on the following criteria: =over 4 =item C, C or C fields Create C_contains and C_lacks arguments =item C, or C fields Create C_before, C_after, C_since and C_until arguments. =item C, C, C, C or C fields Generate C_lt, C_gt, C_le and C_ge arguments, as well as a C_dwim field that accepts a prefixed comparison operator in the search value, such as C<< >100 >> and C<< !100 >>. =back =cut sub arguments { my $self = shift; # The args processing here is involved, so only calculate them once return $self->_cached_arguments if $self->_cached_arguments; # Iterate through all the arguments setup by Jifty::Action::Record my $args = $self->SUPER::arguments; for my $field (keys %$args) { # Figure out what information we know about the field my $info = $args->{$field}; my $column = $self->record->column($field); # We don't care about validation and mandatories on search delete $info->{validator}; delete $info->{mandatory}; # If the column has a set of valid values, deal with those if ($info->{valid_values}) { my $valid_values = $info->{valid_values}; # Canonicalize the valid values local $@; $info->{valid_values} = $valid_values = (eval { [ @$valid_values ] } || [$valid_values]); # For radio display, display an "any" label (empty looks weird) if (defined $info->{render_as} and lc $info->{render_as} eq 'radio') { if (@$valid_values > 1) { unshift @$valid_values, { display => _("(any)"), value => '' }; $info->{default_value} ||= ''; } else { # We've got only one choice anyway... $info->{default_value} ||= $valid_values->[0]; } } # If not radio, add a blank options else { unshift @$valid_values, ""; } } # You can't search passwords, so remove the fields if(defined $info->{'render_as'} and lc $info->{'render_as'} eq 'password') { delete $args->{$field}; next; } # Warn if we have a search field without an actual column warn "No column for: $field" unless($column); # Drop out X-to-many columns from the search if(defined(my $refers_to = $column->refers_to)) { delete $args->{$field} if UNIVERSAL::isa($refers_to, 'Jifty::Collection'); } if ($info->{container}) { delete $args->{$field}; next; } # XXX TODO: What about booleans? Checkbox doesn't quite work, # since there are three choices: yes, no, either. # Magic _id refers_to columns next if($field =~ /^(.*)_id$/ && $self->record->column($1)); # Setup the field label for the comparison operator selection my $label = $info->{label} || $field; # Add the "X is not" operator $args->{"${field}_not"} = { %$info, label => _("%1 is not", $label) }; # The operators available depend on the type my $type = lc($column->type); # Add operators available for text fields if($type =~ /(?:text|char)/) { # Show a text entry box (rather than a textarea) $info->{render_as} = 'text'; # Add the "X contains" operator $args->{"${field}_contains"} = { %$info, label => _("%1 contains", $label) }; # Add the "X lacks" operator (i.e., opposite of "X contains") $args->{"${field}_lacks"} = { %$info, label => _("%1 lacks", $label) }; } # Handle date, datetime, time, and timestamp fields elsif($type =~ /(?:date|time)/) { # Add the "X after" date/time operation $args->{"${field}_after"} = { %$info, label => _("%1 after", $label) }; # Add the "X before" date/time operation $args->{"${field}_before"} = { %$info, label => _("%1 before", $label) }; # Add the "X since" date/time operation $args->{"${field}_since"} = { %$info, label => _("%1 since", $label) }; # Add the "X until" date/time operation $args->{"${field}_until"} = { %$info, label => _("%1 until", $label) }; } # Handle number fields elsif( $type =~ /(?:int|float|double|decimal|numeric)/ && !$column->refers_to) { # Add the "X greater than" operation $args->{"${field}_gt"} = { %$info, label => _("%1 greater than", $label) }; # Add the "X less than" operation $args->{"${field}_lt"} = { %$info, label => _("%1 less than", $label) }; # Add the "X greater than or equal to" operation $args->{"${field}_ge"} = { %$info, label => _("%1 greater or equal to", $label) }; # Add the "X less than or equal to" operation $args->{"${field}_le"} = { %$info, label => _("%1 less or equal to", $label) }; # Add the "X is whatever the heck I say it is" operation $args->{"${field}_dwim"} = { %$info, hints => _('!=>< allowed') }; } } # Add generic contains/lacks search boxes for all fields $args->{contains} = { type => 'text', label => _('Any field contains') }; $args->{lacks} = { type => 'text', label => _('No field contains') }; # Cache the results so we don't have to do THAT again return $self->_cached_arguments($args); } =head2 take_action Return a collection with the result of the search specified by the given arguments. We interpret a C argument as SQL C, and ignore empty or non-present arguments. =cut sub take_action { my $self = shift; # Create a generic collection for our record class my $collection = $self->record_class->collection_class->new( record_class => $self->record_class, current_user => $self->record->current_user ); # Start with an unlimited collection $collection->find_all_rows; # For each field, process the limits for my $field (grep {$self->has_argument($_)} $self->argument_names) { # We process contains last, skip it here next if $field eq 'contains'; # Get the value set on the field my $value = $self->argument_value($field); # Load the column this field belongs to my $column = $self->record->column($field); my $op = undef; # A comparison or substring search rather than an exact match? if (!$column) { # If we don't have a column, this is a comparison or # substring search. Skip undef values for those, since # NULL makes no sense. next unless defined($value); next if $value =~ /^\s*$/; # Decode the field_op name if ($field =~ m{^(.*)_([[:alpha:]]+)$}) { $field = $1; $op = $2; # Convert each operator into limit operators if($op eq 'not') { $op = '!='; } elsif($op eq 'contains') { $op = 'LIKE'; $value = "%$value%"; } elsif($op eq 'lacks') { $op = 'NOT LIKE'; $value = "%$value%"; } elsif($op eq 'after' || $op eq 'gt') { $op = '>'; } elsif($op eq 'before' || $op eq 'lt') { $op = '<'; } elsif($op eq 'since' || $op eq 'ge') { $op = '>='; } elsif($op eq 'until' || $op eq 'le') { $op = '<='; } elsif($op eq 'dwim') { $op = '='; if (defined($value) and $value =~ s/^\s*([<>!=]{1,2})\s*//) { $op = $1; $op = '!=' if $op eq '!'; $op = '=' if $op eq '=='; } } } # Doesn't look like a field_op, skip it else { next; } } # Now, add the limit if we have a value set if (defined($value)) { next if $value =~ /^\s*$/; # skip blank values! # Allow != and NOT LIKE to match NULL columns if ($op && $op =~ /^(?:!=|NOT LIKE)$/) { $collection->limit( column => $field, value => $value, operator => $op, entry_aggregator => 'OR', case_sensitive => 0, ); $collection->limit( column => $field, value => 'NULL', operator => 'IS', ); } # For any others, just the facts please else { $collection->limit( column => $field, value => $value, operator => $op || "=", entry_aggregator => 'AND', $op ? (case_sensitive => 0) : (), ); } } # The value is not defined at all, so expect a NULL else { $collection->limit( column => $field, value => 'NULL', operator => 'IS' ); } } # Handle the general contains last if($self->has_argument('contains')) { # See if any column contains the text described my $any = $self->argument_value('contains'); if (length $any) { for my $col ($self->record->columns) { if($col->type =~ /(?:text|varchar)/) { $collection->limit(column => $col->name, value => "%$any%", operator => 'LIKE', entry_aggregator => 'OR', subclause => 'contains'); } } } } # Add the limited collection to the results $self->result->content(search => $collection); $self->result->success; } =head1 SEE ALSO L, L =head1 LICENSE Jifty is Copyright 2005-2010 Best Practical Solutions, LLC. Jifty is distributed under the same terms as Perl itself. =cut 1;