The Perl Toolchain Summit needs more sponsors. If your company depends on Perl, please support this very important event.
use warnings;
use strict;

=head1 NAME

Jifty::Action::Record::Search - Automagic search action

=head1 DESCRIPTION

The class is a base class for L<Jifty::Action>s that serve to provide
an interface to general searches through L<Jifty::Record> objects. To
use it, subclass it and override the C<record_class> 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<text>, C<char> or C<varchar> fields

Create C<field>_contains and C<field>_lacks arguments

=item C<date>, or C<timestamp> fields

Create C<field>_before, C<field>_after, C<field>_since and
C<field>_until arguments.

=item C<integer>, C<float>, C<double>, C<decimal> or C<numeric> fields

Generate C<field>_lt, C<field>_gt, C<field>_le and C<field>_ge arguments, as
well as a C<field>_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<undef> argument as SQL C<NULL>, 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<Jifty::Action::Record>, L<Jifty::Collection>

=head1 LICENSE

Jifty is Copyright 2005-2010 Best Practical Solutions, LLC.
Jifty is distributed under the same terms as Perl itself.

=cut

1;