# -*- Mode: perl; indent-tabs-mode: nil -*-
#
# The contents of this file are subject to the Mozilla Public
# License Version 1.1 (the "License"); you may not use this file
# except in compliance with the License. You may obtain a copy of
# the License at http://www.mozilla.org/MPL/
#
# Software distributed under the License is distributed on an "AS
# IS" basis, WITHOUT WARRANTY OF ANY KIND, either express or
# implied. See the License for the specific language governing
# rights and limitations under the License.
#
# The Original Code is the Bugzilla Bug Tracking System.
#
# The Initial Developer of the Original Code is Netscape Communications
# Corporation. Portions created by Netscape are
# Copyright (C) 1998 Netscape Communications Corporation. All
# Rights Reserved.
#
# Contributor(s): Gervase Markham <gerv@gerv.net>
#                 Terry Weissman <terry@mozilla.org>
#                 Dan Mosedale <dmose@mozilla.org>
#                 Stephan Niemz <st.n@gmx.net>
#                 Andreas Franke <afranke@mathweb.org>
#                 Myk Melez <myk@mozilla.org>
#                 Michael Schindler <michael@compressconsult.com>
#                 Max Kanat-Alexander <mkanat@bugzilla.org>
#                 Joel Peshkin <bugreport@peshkin.net>
#                 Lance Larsh <lance.larsh@oracle.com>
#                 Jesse Clark <jjclark1982@gmail.com>
#                 RĂ©mi Zara <remi_zara@mac.com>
#                 Reed Loden <reed@reedloden.com>

use strict;

package Bugzilla::Search;
use base qw(Exporter);
@Bugzilla::Search::EXPORT = qw(
    EMPTY_COLUMN

    IsValidQueryType
    split_order_term
    translate_old_column
);

use Bugzilla::Error;
use Bugzilla::Util;
use Bugzilla::Constants;
use Bugzilla::Group;
use Bugzilla::User;
use Bugzilla::Field;
use Bugzilla::Status;
use Bugzilla::Keyword;

use Data::Dumper;
use Date::Format;
use Date::Parse;
use List::MoreUtils qw(all part uniq);
use Storable qw(dclone);

# Description Of Boolean Charts
# -----------------------------
#
# A boolean chart is a way of representing the terms in a logical
# expression.  Bugzilla builds SQL queries depending on how you enter
# terms into the boolean chart. Boolean charts are represented in
# urls as three-tuples of (chart id, row, column). The query form
# (query.cgi) may contain an arbitrary number of boolean charts where
# each chart represents a clause in a SQL query.
#
# The query form starts out with one boolean chart containing one
# row and one column.  Extra rows can be created by pressing the
# AND button at the bottom of the chart.  Extra columns are created
# by pressing the OR button at the right end of the chart. Extra
# charts are created by pressing "Add another boolean chart".
#
# Each chart consists of an arbitrary number of rows and columns.
# The terms within a row are ORed together. The expressions represented
# by each row are ANDed together. The expressions represented by each
# chart are ANDed together.
#
#        ----------------------
#        | col2 | col2 | col3 |
# --------------|------|------|
# | row1 |  a1  |  a2  |      |
# |------|------|------|------|  => ((a1 OR a2) AND (b1 OR b2 OR b3) AND (c1))
# | row2 |  b1  |  b2  |  b3  |
# |------|------|------|------|
# | row3 |  c1  |      |      |
# -----------------------------
#
#        --------
#        | col2 |
# --------------|
# | row1 |  d1  | => (d1)
# ---------------
#
# Together, these two charts represent a SQL expression like this
# SELECT blah FROM blah WHERE ( (a1 OR a2)AND(b1 OR b2 OR b3)AND(c1)) AND (d1)
#
# The terms within a single row of a boolean chart are all constraints
# on a single piece of data.  If you're looking for a bug that has two
# different people cc'd on it, then you need to use two boolean charts.
# This will find bugs with one CC matching 'foo@blah.org' and and another
# CC matching 'bar@blah.org'.
#
# --------------------------------------------------------------
# CC    | equal to
# foo@blah.org
# --------------------------------------------------------------
# CC    | equal to
# bar@blah.org
#
# If you try to do this query by pressing the AND button in the
# original boolean chart then what you'll get is an expression that
# looks for a single CC where the login name is both "foo@blah.org",
# and "bar@blah.org". This is impossible.
#
# --------------------------------------------------------------
# CC    | equal to
# foo@blah.org
# AND
# CC    | equal to
# bar@blah.org
# --------------------------------------------------------------


#############
# Constants #
#############

# If you specify a search type in the boolean charts, this describes
# which operator maps to which internal function here.
use constant OPERATORS => {
    equals         => \&_simple_operator,
    notequals      => \&_simple_operator,
    casesubstring  => \&_casesubstring,
    substring      => \&_substring,
    substr         => \&_substring,
    notsubstring   => \&_notsubstring,
    regexp         => \&_regexp,
    notregexp      => \&_notregexp,
    lessthan       => \&_simple_operator,
    lessthaneq     => \&_simple_operator,
    matches        => sub { ThrowUserError("search_content_without_matches"); },
    notmatches     => sub { ThrowUserError("search_content_without_matches"); },
    greaterthan    => \&_simple_operator,
    greaterthaneq  => \&_simple_operator,
    anyexact       => \&_anyexact,
    anywordssubstr => \&_anywordsubstr,
    allwordssubstr => \&_allwordssubstr,
    nowordssubstr  => \&_nowordssubstr,
    anywords       => \&_anywords,
    allwords       => \&_allwords,
    nowords        => \&_nowords,
    changedbefore  => \&_changedbefore_changedafter,
    changedafter   => \&_changedbefore_changedafter,
    changedfrom    => \&_changedfrom_changedto,
    changedto      => \&_changedfrom_changedto,
    changedby      => \&_changedby,
};

# Some operators are really just standard SQL operators, and are
# all implemented by the _simple_operator function, which uses this
# constant.
use constant SIMPLE_OPERATORS => {
    equals        => '=',
    notequals     => '!=',
    greaterthan   => '>',
    greaterthaneq => '>=',
    lessthan      => '<',
    lessthaneq    => "<=",
};

# Most operators just reverse by removing or adding "not" from/to them.
# However, some operators reverse in a different way, so those are listed
# here.
use constant OPERATOR_REVERSE => {
    nowords        => 'anywords',
    nowordssubstr  => 'anywordssubstr',
    anywords       => 'nowords',
    anywordssubstr => 'nowordssubstr',
    lessthan       => 'greaterthaneq',
    lessthaneq     => 'greaterthan',
    greaterthan    => 'lessthaneq',
    greaterthaneq  => 'lessthan',
    # The following don't currently have reversals:
    # casesubstring, anyexact, allwords, allwordssubstr
};

use constant OPERATOR_FIELD_OVERRIDE => {
    # User fields
    'attachments.submitter' => {
        _non_changed => \&_user_nonchanged,
    },
    assigned_to => {
        _non_changed => \&_user_nonchanged,
    },
    cc => {
        _non_changed => \&_user_nonchanged,
    },
    commenter => {
        _non_changed => \&_user_nonchanged,
    },
    reporter => {
        _non_changed => \&_user_nonchanged,
    },
    'requestees.login_name' => {
        _non_changed => \&_user_nonchanged,
    },
    'setters.login_name' => {
        _non_changed => \&_user_nonchanged,    
    },
    qa_contact => {
        _non_changed => \&_user_nonchanged,
    },
    
    # General Bug Fields
    alias => {
        _non_changed => \&_alias_nonchanged,
    },
    'attach_data.thedata' => {
        _non_changed => \&_attach_data_thedata,
    },
    # We check all attachment fields against this.
    'attachments' => {
        _non_changed => \&_attachments,
    },
    blocked => {
        _non_changed => \&_blocked_nonchanged,
    },
    bug_group => {
        _non_changed => \&_bug_group_nonchanged,
    },
    classification => {
        _non_changed => \&_classification_nonchanged,
    },
    component => {
        _non_changed => \&_component_nonchanged,
    },
    content => {
        matches    => \&_content_matches,
        notmatches => \&_content_matches,
        _default   => sub { ThrowUserError("search_content_without_matches"); },
    },
    days_elapsed => {
        _default => \&_days_elapsed,
    },
    dependson => {
        _non_changed => \&_dependson_nonchanged,
    },
    keywords => {
        equals       => \&_keywords_exact,
        anyexact     => \&_keywords_exact,
        anyword      => \&_keywords_exact,
        allwords     => \&_keywords_exact,

        notequals     => \&_multiselect_negative,
        notregexp     => \&_multiselect_negative,
        notsubstring  => \&_multiselect_negative,
        nowords       => \&_multiselect_negative,
        nowordssubstr => \&_multiselect_negative,

        _non_changed  => \&_keywords_nonchanged,
    },
    'flagtypes.name' => {
        _default => \&_flagtypes_name,
    },    
    longdesc => {
        changedby     => \&_long_desc_changedby,
        changedbefore => \&_long_desc_changedbefore_after,
        changedafter  => \&_long_desc_changedbefore_after,
        _default      => \&_long_desc,
    },
    'longdescs.isprivate' => {
        _default => \&_longdescs_isprivate,
    },
    owner_idle_time => {
        greaterthan   => \&_owner_idle_time_greater_less,
        greaterthaneq => \&_owner_idle_time_greater_less,
        lessthan      => \&_owner_idle_time_greater_less,
        lessthaneq    => \&_owner_idle_time_greater_less,
        _default      => \&_invalid_combination,
    },
    
    product => {
        _non_changed => \&_product_nonchanged,
    },
    
    # Custom multi-select fields
    _multi_select => {
        notequals      => \&_multiselect_negative,
        notregexp      => \&_multiselect_negative,
        notsubstring   => \&_multiselect_negative,
        nowords        => \&_multiselect_negative,
        nowordssubstr  => \&_multiselect_negative,
        
        allwords       => \&_multiselect_multiple,
        allwordssubstr => \&_multiselect_multiple,
        anyexact       => \&_multiselect_multiple,
        
        _non_changed    => \&_multiselect_nonchanged,
    },
    
    # Timetracking Fields
    percentage_complete => {
        _non_changed => \&_percentage_complete,
    },
    work_time => {
        changedby     => \&_work_time_changedby,
        changedbefore => \&_work_time_changedbefore_after,
        changedafter  => \&_work_time_changedbefore_after,
        _default      => \&_work_time,
    },
    
};

# These are fields where special action is taken depending on the
# *value* passed in to the chart, sometimes.
use constant SPECIAL_PARSING => {
    # Pronoun Fields (Ones that can accept %user%, etc.)
    assigned_to => \&_contact_pronoun,
    cc          => \&_cc_pronoun,
    commenter   => \&_commenter_pronoun,
    qa_contact  => \&_contact_pronoun,
    reporter    => \&_contact_pronoun,
    
    # Date Fields that accept the 1d, 1w, 1m, 1y, etc. format.
    creation_ts => \&_timestamp_translate,
    deadline    => \&_timestamp_translate,
    delta_ts    => \&_timestamp_translate,
};

# Information about fields that represent "users", used by _user_nonchanged.
# There are other user fields than the ones listed here, but those use
# defaults in _user_nonchanged.
use constant USER_FIELDS => {
    'attachments.submitter' => {
        field    => 'submitter_id',
        join     => { table => 'attachments' },
        isprivate => 1,
    },
    cc => {
        field => 'who',
        join  => { table => 'cc' },
    },
    commenter => {
        field => 'who',
        join  => { table => 'longdescs', join => 'INNER' },
        isprivate => 1,
    },
    qa_contact => {
        nullable => 1,
    },
    'requestees.login_name' => {
        nullable => 1,
        field    => 'requestee_id',
        join     => { table => 'flags' },
    },
    'setters.login_name' => {
        field    => 'setter_id',
        join     => { table => 'flags' },
    },
};

# Backwards compatibility for times that we changed the names of fields
# or URL parameters.
use constant FIELD_MAP => {
    bugidtype => 'bug_id_type',
    changedin => 'days_elapsed',
    long_desc => 'longdesc',
};

# A SELECTed expression that we use as a placeholder if somebody selects
# <none> for the X, Y, or Z axis in report.cgi.
use constant EMPTY_COLUMN => '-1';

# A special value that is pushed into charts during _params_to_charts to
# represent that the particular chart we're dealing with should be negated.
use constant NEGATE => 'NOT';

# Some fields are not sorted on themselves, but on other fields. 
# We need to have a list of these fields and what they map to.
use constant SPECIAL_ORDER => {
    'target_milestone' => {
        order => ['map_target_milestone.sortkey','map_target_milestone.value'],
        join  => {
            table => 'milestones',
            from  => 'target_milestone',
            to    => 'value',
            extra => ['bugs.product_id = map_target_milestone.product_id'],
            join  => 'INNER',
        }
    },
};

# Certain columns require other columns to come before them
# in _select_columns, and should be put there if they're not there.
use constant COLUMN_DEPENDS => {
    classification      => ['product'],
    percentage_complete => ['actual_time', 'remaining_time'],
};

# This describes tables that must be joined when you want to display
# certain columns in the buglist. For the most part, Search.pm uses
# DB::Schema to figure out what needs to be joined, but for some
# fields it needs a little help.
use constant COLUMN_JOINS => {
    assigned_to => {
        from  => 'assigned_to',
        to    => 'userid',
        table => 'profiles',
        join  => 'INNER',
    },
    reporter => {
        from  => 'reporter',
        to    => 'userid',
        table => 'profiles',
        join  => 'INNER',
    },
    qa_contact => {
        from  => 'qa_contact',
        to    => 'userid',
        table => 'profiles',
    },
    component => {
        from  => 'component_id',
        to    => 'id',
        table => 'components',
        join  => 'INNER',
    },
    product => {
        from  => 'product_id',
        to    => 'id',
        table => 'products',
        join  => 'INNER',
    },
    classification => {
        table => 'classifications',
        from  => 'map_product.classification_id',
        to    => 'id',
        join  => 'INNER',
    },
    actual_time => {
        table  => 'longdescs',
        join   => 'INNER',
    },
    'flagtypes.name' => {
        as    => 'map_flags',
        table => 'flags',
        extra => ['attach_id IS NULL'],
        then_to => {
            as    => 'map_flagtypes',
            table => 'flagtypes',
            from  => 'map_flags.type_id',
            to    => 'id',
        },
    },
    keywords => {
        table => 'keywords',
        then_to => {
            as    => 'map_keyworddefs',
            table => 'keyworddefs',
            from  => 'map_keywords.keywordid',
            to    => 'id',
        },
    },
};

# This constant defines the columns that can be selected in a query 
# and/or displayed in a bug list.  Column records include the following
# fields:
#
# 1. id: a unique identifier by which the column is referred in code;
#
# 2. name: The name of the column in the database (may also be an expression
#          that returns the value of the column);
#
# 3. title: The title of the column as displayed to users.
# 
# Note: There are a few hacks in the code that deviate from these definitions.
#       In particular, the redundant short_desc column is removed when the
#       client requests "all" columns.
#
# This is really a constant--that is, once it's been called once, the value
# will always be the same unless somebody adds a new custom field. But
# we have to do a lot of work inside the subroutine to get the data,
# and we don't want it to happen at compile time, so we have it as a
# subroutine.
sub COLUMNS {
    my $dbh = Bugzilla->dbh;
    my $cache = Bugzilla->request_cache;
    return $cache->{search_columns} if defined $cache->{search_columns};

    # These are columns that don't exist in fielddefs, but are valid buglist
    # columns. (Also see near the bottom of this function for the definition
    # of short_short_desc.)
    my %columns = (
        relevance            => { title => 'Relevance'  },
        assigned_to_realname => { title => 'Assignee'   },
        reporter_realname    => { title => 'Reporter'   },
        qa_contact_realname  => { title => 'QA Contact' },
    );

    # Next we define columns that have special SQL instead of just something
    # like "bugs.bug_id".
    my $actual_time = '(SUM(map_actual_time.work_time)'
        . ' * COUNT(DISTINCT map_actual_time.bug_when)/COUNT(bugs.bug_id))';
    my %special_sql = (
        deadline    => $dbh->sql_date_format('bugs.deadline', '%Y-%m-%d'),
        actual_time => $actual_time,

        percentage_complete =>
            "(CASE WHEN $actual_time + bugs.remaining_time = 0.0"
              . " THEN 0.0"
              . " ELSE 100"
                   . " * ($actual_time / ($actual_time + bugs.remaining_time))"
              . " END)",

        'flagtypes.name' => $dbh->sql_group_concat('DISTINCT ' 
            . $dbh->sql_string_concat('map_flagtypes.name', 'map_flags.status')),

        'keywords' => $dbh->sql_group_concat('DISTINCT map_keyworddefs.name'),
    );

    # Backward-compatibility for old field names. Goes new_name => old_name.
    # These are here and not in translate_old_column because the rest of the
    # code actually still uses the old names, while the fielddefs table uses
    # the new names (which is not the case for the fields handled by 
    # translate_old_column).
    my %old_names = (
        creation_ts => 'opendate',
        delta_ts    => 'changeddate',
        work_time   => 'actual_time',
    );

    # Fields that are email addresses
    my @email_fields = qw(assigned_to reporter qa_contact);
    # Other fields that are stored in the bugs table as an id, but
    # should be displayed using their name.
    my @id_fields = qw(product component classification);

    foreach my $col (@email_fields) {
        my $sql = "map_${col}.login_name";
        # XXX This needs to be generated inside an accessor instead,
        #     probably, because it should use $self->_user to determine
        #     this, not Bugzilla->user.
        if (!Bugzilla->user->id) {
             $sql = $dbh->sql_string_until($sql, $dbh->quote('@'));
        }
        $special_sql{$col} = $sql;
        $columns{"${col}_realname"}->{name} = "map_${col}.realname";
    }

    foreach my $col (@id_fields) {
        $special_sql{$col} = "map_${col}.name";
    }

    # Do the actual column-getting from fielddefs, now.
    my @fields = @{ Bugzilla->fields({ obsolete => 0, buglist => 1 }) };
    foreach my $field (@fields) {
        my $id = $field->name;
        $id = $old_names{$id} if exists $old_names{$id};
        my $sql;
        if (exists $special_sql{$id}) {
            $sql = $special_sql{$id};
        }
        elsif ($field->type == FIELD_TYPE_MULTI_SELECT) {
            $sql = $dbh->sql_group_concat(
                'DISTINCT map_' . $field->name . '.value');
        }
        else {
            $sql = 'bugs.' . $field->name;
        }
        $columns{$id} = { name => $sql, title => $field->description };
    }

    # The short_short_desc column is identical to short_desc
    $columns{'short_short_desc'} = $columns{'short_desc'};

    Bugzilla::Hook::process('buglist_columns', { columns => \%columns });

    $cache->{search_columns} = \%columns;
    return $cache->{search_columns};
}

sub REPORT_COLUMNS {
    my $columns = dclone(COLUMNS);
    # There's no reason to support reporting on unique fields.
    # Also, some other fields don't make very good reporting axises,
    # or simply don't work with the current reporting system.
    my @no_report_columns = 
        qw(bug_id alias short_short_desc opendate changeddate
           flagtypes.name keywords relevance);

    # Multi-select fields are not currently supported.
    my @multi_selects = @{Bugzilla->fields(
        { obsolete => 0, type => FIELD_TYPE_MULTI_SELECT })};
    push(@no_report_columns, map { $_->name } @multi_selects);

    # If you're not a time-tracker, you can't use time-tracking
    # columns.
    if (!Bugzilla->user->is_timetracker) {
        push(@no_report_columns, TIMETRACKING_FIELDS);
    }

    foreach my $name (@no_report_columns) {
        delete $columns->{$name};
    }
    return $columns;
}

# These are fields that never go into the GROUP BY on any DB. bug_id
# is here because it *always* goes into the GROUP BY as the first item,
# so it should be skipped when determining extra GROUP BY columns.
use constant GROUP_BY_SKIP => EMPTY_COLUMN, qw(
    actual_time
    bug_id
    flagtypes.name
    keywords
    percentage_complete
);

###############
# Constructor #
###############

# Note that the params argument may be modified by Bugzilla::Search
sub new {
    my $invocant = shift;
    my $class = ref($invocant) || $invocant;
  
    my $self = { @_ };
    bless($self, $class);
    $self->{'user'} ||= Bugzilla->user;
    
    # There are certain behaviors of the CGI "Vars" hash that we don't want.
    # In particular, if you put a single-value arrayref into it, later you
    # get back out a string, which breaks anyexact charts (because they
    # need arrays even for individual items, or we will re-trigger bug 67036).
    #
    # We can't just untie the hash--that would give us a hash with no values.
    # We have to manually copy the hash into a new one, and we have to always
    # do it, because there's no way to know if we were passed a tied hash
    # or not.
    my $params_in = $self->_params;
    my %params = map { $_ => $params_in->{$_} } keys %$params_in;
    $self->{params} = \%params;

    return $self;
}


####################
# Public Accessors #
####################

sub sql {
    my ($self) = @_;
    return $self->{sql} if $self->{sql};
    my $dbh = Bugzilla->dbh;
    
    my ($joins, $having_terms, $where_terms) = $self->_charts_to_conditions();

    my $select = join(', ', $self->_sql_select);
    my $from = $self->_sql_from($joins);
    my $where = $self->_sql_where($where_terms);
    my $group_by = $dbh->sql_group_by($self->_sql_group_by);
    my $having = @$having_terms
                 ? "\nHAVING " . join(' AND ', @$having_terms) : '';
    my $order_by = $self->_sql_order_by
                   ? "\nORDER BY " . join(', ', $self->_sql_order_by) : '';
    
    my $query = <<END;
SELECT $select
  FROM $from
 WHERE $where
$group_by$having$order_by
END
    $self->{sql} = $query;
    return $self->{sql};
}

sub search_description {
    my ($self, $params) = @_;
    my $desc = $self->{'search_description'} ||= [];
    if ($params) {
        push(@$desc, $params);
    }
    # Make sure that the description has actually been generated if
    # people are asking for the whole thing.
    else {
        $self->sql;
    }
    return $self->{'search_description'};
}

######################
# Internal Accessors #
######################

# Fields that are legal for boolean charts of any kind.
sub _chart_fields {
    my ($self) = @_;

    if (!$self->{chart_fields}) {
        my $chart_fields = Bugzilla->fields({ by_name => 1 });

        if (!$self->_user->is_timetracker) {
            foreach my $tt_field (TIMETRACKING_FIELDS) {
                delete $chart_fields->{$tt_field};
            }
        }
        $self->{chart_fields} = $chart_fields;
    }
    return $self->{chart_fields};
}

# There are various places in Search.pm that we need to know the list of
# valid multi-select fields--or really, fields that are stored like
# multi-selects, which includes BUG_URLS fields.
sub _multi_select_fields {
    my ($self) = @_;
    $self->{multi_select_fields} ||= Bugzilla->fields({
        by_name => 1,
        type    => [FIELD_TYPE_MULTI_SELECT, FIELD_TYPE_BUG_URLS]});
    return $self->{multi_select_fields};
}

# $self->{params} contains values that could be undef, could be a string,
# or could be an arrayref. Sometimes we want that value as an array,
# always.
sub _param_array {
    my ($self, $name) = @_;
    my $value = $self->_params->{$name};
    if (!defined $value) {
        return ();
    }
    if (ref($value) eq 'ARRAY') {
        return @$value;
    }
    return ($value);
}

sub _params { $_[0]->{params} }

sub _user { return $_[0]->{user} }

##############################
# Internal Accessors: SELECT #
##############################

# These are the fields the user has chosen to display on the buglist,
# exactly as they were passed to new().
sub _input_columns { @{ $_[0]->{'fields'} || [] } }

# These are columns that are also going to be in the SELECT for one reason
# or another, but weren't actually requested by the caller.
sub _extra_columns {
    my ($self) = @_;
    # Everything that's going to be in the ORDER BY must also be
    # in the SELECT.
    $self->{extra_columns} ||= [ $self->_input_order_columns ];
    return @{ $self->{extra_columns} };
}

# For search functions to modify extra_columns. It doesn't matter if
# people push the same column onto this array multiple times, because
# _select_columns will call "uniq" on its final result.
sub _add_extra_column {
    my ($self, $column) = @_;
    push(@{ $self->{extra_columns} }, $column);
}

# These are the columns that we're going to be actually SELECTing.
sub _select_columns {
    my ($self) = @_;
    return @{ $self->{select_columns} } if $self->{select_columns};

    my @select_columns;
    foreach my $column ($self->_input_columns, $self->_extra_columns) {
        if (my $add_first = COLUMN_DEPENDS->{$column}) {
            push(@select_columns, @$add_first);
        }
        push(@select_columns, $column);
    }
    
    $self->{select_columns} = [uniq @select_columns];
    return @{ $self->{select_columns} };
}

# This takes _select_columns and translates it into the actual SQL that
# will go into the SELECT clause.
sub _sql_select {
    my ($self) = @_;
    my @sql_fields;
    foreach my $column ($self->_select_columns) {
        my $alias = $column;
        # Aliases cannot contain dots in them. We convert them to underscores.
        $alias =~ s/\./_/g;
        my $sql = ($column eq EMPTY_COLUMN)
                  ? EMPTY_COLUMN : COLUMNS->{$column}->{name} . " AS $alias";
        push(@sql_fields, $sql);
    }
    return @sql_fields;
}

################################
# Internal Accessors: ORDER BY #
################################

# The "order" that was requested by the consumer, exactly as it was
# requested.
sub _input_order { @{ $_[0]->{'order'} || [] } }
# The input order with just the column names, and no ASC or DESC.
sub _input_order_columns {
    my ($self) = @_;
    return map { (split_order_term($_))[0] } $self->_input_order;
}

# A hashref that describes all the special stuff that has to be done
# for various fields if they go into the ORDER BY clause.
sub _special_order {
    my ($self) = @_;
    return $self->{special_order} if $self->{special_order};
    
    my %special_order = %{ SPECIAL_ORDER() };
    my $select_fields = Bugzilla->fields({ type => FIELD_TYPE_SINGLE_SELECT });
    foreach my $field (@$select_fields) {
        next if $field->is_abnormal;
        my $name = $field->name;
        $special_order{$name} = {
            order => ["map_$name.sortkey", "map_$name.value"],
            join  => {
                table => $name,
                from  => "bugs.$name",
                to    => "value",
                join  => 'INNER',
            }
        };
    }
    $self->{special_order} = \%special_order;
    return $self->{special_order};
}

sub _sql_order_by {
    my ($self) = @_;
    if (!$self->{sql_order_by}) {
        my @order_by = map { $self->_translate_order_by_column($_) }
                           $self->_input_order;
        $self->{sql_order_by} = \@order_by;
    }
    return @{ $self->{sql_order_by} };
}

sub _translate_order_by_column {
    my ($self, $order_by_item) = @_;

    my ($field, $direction) = split_order_term($order_by_item);
    
    $direction = '' if lc($direction) eq 'asc';
    my $special_order = $self->_special_order->{$field}->{order};
    # Standard fields have underscores in their SELECT alias instead
    # of a period (because aliases can't have periods).
    $field =~ s/\./_/g;
    my @items = $special_order ? @$special_order : $field;
    if (lc($direction) eq 'desc') {
        @items = map { "$_ DESC" } @items;
    }
    return @items;
}

############################
# Internal Accessors: FROM #
############################

sub _column_join {
    my ($self, $field) = @_;
    my $join_info = COLUMN_JOINS->{$field};
    if ($join_info) {
        # Don't allow callers to modify the constant.
        $join_info = dclone($join_info);
    }
    else {
        if ($self->_multi_select_fields->{$field}) {
            $join_info = { table => "bug_$field" };
        }
    }
    if ($join_info and !$join_info->{as}) {
        $join_info = dclone($join_info);
        $join_info->{as} = "map_$field";
    }
    return $join_info ? $join_info : ();
}

# Sometimes we join the same table more than once. In this case, we
# want to AND all the various critiera that were used in both joins.
sub _combine_joins {
    my ($self, $joins) = @_;
    my @result;
    while(my $join = shift @$joins) {
        my $name = $join->{as};
        my ($others_like_me, $the_rest) = part { $_->{as} eq $name ? 0 : 1 }
                                               @$joins;
        if ($others_like_me) {
            my $from = $join->{from};
            my $to   = $join->{to};
            # Sanity check to make sure that we have the same from and to
            # for all the same-named joins.
            if ($from) {
                all { $_->{from} eq $from } @$others_like_me
                  or die "Not all same-named joins have identical 'from': "
                         . Dumper($join, $others_like_me);
            }
            if ($to) {
                all { $_->{to} eq $to } @$others_like_me
                  or die "Not all same-named joins have identical 'to': "
                         . Dumper($join, $others_like_me);
            }
            
            # We don't need to call uniq here--translate_join will do that
            # for us.
            my @conditions = map { @{ $_->{extra} || [] } }
                                 ($join, @$others_like_me);
            $join->{extra} = \@conditions;
            $joins = $the_rest;
        }
        push(@result, $join);
    }
    
    return @result;
}

# Takes all the "then_to" items and just puts them as the next item in
# the array. Right now this only does one level of "then_to", but we
# could re-write this to handle then_to recursively if we need more levels.
sub _extract_then_to {
    my ($self, $joins) = @_;
    my @result;
    foreach my $join (@$joins) {
        push(@result, $join);
        if (my $then_to = $join->{then_to}) {
            push(@result, $then_to);
        }
    }
    return @result;
}

# JOIN statements for the SELECT and ORDER BY columns. This should not be
# called until the moment it is needed, because _select_columns might be
# modified by the charts.
sub _select_order_joins {
    my ($self) = @_;
    my @joins;
    foreach my $field ($self->_select_columns) {
        my @column_join = $self->_column_join($field);
        push(@joins, @column_join);
    }
    foreach my $field ($self->_input_order_columns) {
        my $join_info = $self->_special_order->{$field}->{join};
        if ($join_info) {
            # Don't let callers modify SPECIAL_ORDER.
            $join_info = dclone($join_info);
            if (!$join_info->{as}) {
                $join_info->{as} = "map_$field";
            }
            push(@joins, $join_info);
        }
    }
    return @joins;
}

# These are the joins that are *always* in the FROM clause.
sub _standard_joins {
    my ($self) = @_;
    my $user = $self->_user;
    my @joins;

    my $security_join = {
        table => 'bug_group_map',
        as    => 'security_map',
    };
    push(@joins, $security_join);

    if ($user->id) {
        $security_join->{extra} =
            ["NOT (" . $user->groups_in_sql('security_map.group_id') . ")"];
            
        my $security_cc_join = {
            table => 'cc',
            as    => 'security_cc',
            extra => ['security_cc.who = ' . $user->id],
        };
        push(@joins, $security_cc_join);
    }
    
    return @joins;
}

sub _sql_from {
    my ($self, $joins_input) = @_;
    my @joins = ($self->_standard_joins, $self->_select_order_joins,
                 @$joins_input);
    @joins = $self->_extract_then_to(\@joins);
    @joins = $self->_combine_joins(\@joins);
    my @join_sql = map { $self->_translate_join($_) } @joins;
    return "bugs\n" . join("\n", @join_sql);
}

# This takes a join data structure and turns it into actual JOIN SQL.
sub _translate_join {
    my ($self, $join_info) = @_;
    
    die "join with no table: " . Dumper($join_info) if !$join_info->{table};
    die "join with no 'as': " . Dumper($join_info) if !$join_info->{as};
        
    my $from_table = "bugs";
    my $from  = $join_info->{from} || "bug_id";
    if ($from =~ /^(\w+)\.(\w+)$/) {
        ($from_table, $from) = ($1, $2);
    }
    my $table = $join_info->{table};
    my $name  = $join_info->{as};
    my $to    = $join_info->{to}    || "bug_id";
    my $join  = $join_info->{join}  || 'LEFT';
    my @extra = @{ $join_info->{extra} || [] };
    $name =~ s/\./_/g;
    
    # If a term contains ORs, we need to put parens around the condition.
    # This is a pretty weak test, but it's actually OK to put parens
    # around too many things.
    @extra = map { $_ =~ /\bOR\b/i ? "($_)" : $_ } @extra;
    my $extra_condition = join(' AND ', uniq @extra);
    if ($extra_condition) {
        $extra_condition = " AND $extra_condition";
    }

    my @join_sql = "$join JOIN $table AS $name"
                        . " ON $from_table.$from = $name.$to$extra_condition";
    return @join_sql;
}

#############################
# Internal Accessors: WHERE #
#############################

# Note: There's also quite a bit of stuff that affects the WHERE clause
# in the "Internal Accessors: Boolean Charts" section.

# The terms that are always in the WHERE clause. These implement bug
# group security.
sub _standard_where {
    my ($self) = @_;
    # If replication lags badly between the shadow db and the main DB,
    # it's possible for bugs to show up in searches before their group
    # controls are properly set. To prevent this, when initially creating
    # bugs we set their creation_ts to NULL, and don't give them a creation_ts
    # until their group controls are set. So if a bug has a NULL creation_ts,
    # it shouldn't show up in searches at all.
    my @where = ('bugs.creation_ts IS NOT NULL');
    
    my $security_term = 'security_map.group_id IS NULL';

    my $user = $self->_user;
    if ($user->id) {
        my $userid = $user->id;
        $security_term .= <<END;
 OR (bugs.reporter_accessible = 1 AND bugs.reporter = $userid)
 OR (bugs.cclist_accessible = 1 AND security_cc.who IS NOT NULL)
 OR bugs.assigned_to = $userid
END
        if (Bugzilla->params->{'useqacontact'}) {
            $security_term.= " OR bugs.qa_contact = $userid";
        }
        $security_term = "($security_term)";
    }

    push(@where, $security_term);

    return @where;
}

sub _sql_where {
    my ($self, $where_terms) = @_;
    # The newline and this particular spacing makes the resulting
    # SQL a bit more readable for debugging.
    return join("\n   AND ", $self->_standard_where, @$where_terms);
}

################################
# Internal Accessors: GROUP BY #
################################

# And these are the fields that we have to do GROUP BY for in DBs
# that are more strict about putting everything into GROUP BY.
sub _sql_group_by {
    my ($self) = @_;

    # Strict DBs require every element from the SELECT to be in the GROUP BY,
    # unless that element is being used in an aggregate function.
    my @extra_group_by;
    foreach my $column ($self->_select_columns) {
        next if $self->_skip_group_by->{$column};
        my $sql = COLUMNS->{$column}->{name};
        push(@extra_group_by, $sql);
    }

    # And all items from ORDER BY must be in the GROUP BY. The above loop 
    # doesn't catch items that were put into the ORDER BY from SPECIAL_ORDER.
    foreach my $column ($self->_input_order_columns) {
        my $special_order = $self->_special_order->{$column}->{order};
        next if !$special_order;
        push(@extra_group_by, @$special_order);
    }
    
    @extra_group_by = uniq @extra_group_by;
    
    # bug_id is the only field we actually group by.
    return ('bugs.bug_id', join(',', @extra_group_by));
}

# A helper for _sql_group_by.
sub _skip_group_by {
    my ($self) = @_;
    return $self->{skip_group_by} if $self->{skip_group_by};
    my @skip_list = GROUP_BY_SKIP;
    push(@skip_list, keys %{ $self->_multi_select_fields });
    my %skip_hash = map { $_ => 1 } @skip_list;
    $self->{skip_group_by} = \%skip_hash;
    return $self->{skip_group_by};
}

##############################################
# Internal Accessors: Special Params Parsing #
##############################################

# Backwards compatibility for old field names.
sub _convert_old_params {
    my ($self) = @_;
    my $params = $self->_params;
    
    # bugidtype has different values in modern Search.pm.
    if (defined $params->{'bugidtype'}) {
        my $value = $params->{'bugidtype'};
        $params->{'bugidtype'} = $value eq 'exclude' ? 'nowords' : 'anyexact';
    }
    
    foreach my $old_name (keys %{ FIELD_MAP() }) {
        if (defined $params->{$old_name}) {
            my $new_name = FIELD_MAP->{$old_name};
            $params->{$new_name} = delete $params->{$old_name};
        }
    }
}

sub _convert_special_params_to_chart_params {
    my ($self) = @_;
    my $params = $self->_params;
    
    my @special_charts = $self->_special_charts();
    
    # First we delete any sign of "Chart #-1" from the input parameters,
    # because we want to guarantee the user didn't hide something there.
    my @badcharts = grep { /^(field|type|value)-1-/ } keys %$params;
    foreach my $field (@badcharts) {
        delete $params->{$field};
    }

    # now we take our special chart and stuff it into the form hash
    my $chart = -1;
    my $and = 0;
    foreach my $or_array (@special_charts) {
        my $or = 0;
        my $identifier = "$chart-$and-$or";
        while (@$or_array) {
            $params->{"field$identifier"} = shift @$or_array;
            $params->{"type$identifier"}  = shift @$or_array;
            $params->{"value$identifier"} = shift @$or_array;
            $or++;
        }
        $and++;
    }
}

# This parses all the standard search parameters except for the boolean
# charts.
sub _special_charts {
    my ($self) = @_;
    $self->_convert_old_params();
    $self->_special_parse_bug_status();
    $self->_special_parse_resolution();
    my @charts = $self->_parse_basic_fields();
    push(@charts, $self->_special_parse_email());
    push(@charts, $self->_special_parse_chfield());
    push(@charts, $self->_special_parse_deadline());
    return @charts;
}

sub _parse_basic_fields {
    my ($self) = @_;
    my $params = $self->_params;
    my $chart_fields = $self->_chart_fields;
    
    my @charts;
    foreach my $field_name (keys %$chart_fields) {
        # CGI params shouldn't have periods in them, so we only accept
        # period-separated fields with underscores where the periods go.
        my $param_name = $field_name;
        $param_name =~ s/\./_/g;
        my @values = $self->_param_array($param_name);
        next if !@values;
        my $operator = $params->{"${param_name}_type"} || 'anyexact';
        $operator = 'matches' if $param_name eq 'content';
        # Fields that are displayed as multi-selects are passed as arrays,
        # so that they can properly search values that contain commas.
        # However, other fields are sent as strings, so that they are properly
        # split on commas if required.
        my $field = $chart_fields->{$field_name};
        my $pass_value;
        if ($field->is_select or $field->name eq 'version'
            or $field->name eq 'target_milestone')
        {
            $pass_value = \@values;
        }
        else {
            $pass_value = join(',', @values);
        }
        push(@charts, [$field_name, $operator, $pass_value]);
    }
    return @charts;
}

sub _special_parse_bug_status {
    my ($self) = @_;
    my $params = $self->_params;
    return if !defined $params->{'bug_status'};

    my @bug_status = $self->_param_array('bug_status');
    # Also include inactive bug statuses, as you can query them.
    my $legal_statuses = $self->_chart_fields->{'bug_status'}->legal_values;

    # If the status contains __open__ or __closed__, translate those
    # into their equivalent lists of open and closed statuses.
    if (grep { $_ eq '__open__' } @bug_status) {
        my @open = grep { $_->is_open } @$legal_statuses;
        @open = map { $_->name } @open;
        push(@bug_status, @open);
    }
    if (grep { $_ eq '__closed__' } @bug_status) {
        my @closed = grep { not $_->is_open } @$legal_statuses;
        @closed = map { $_->name } @closed;
        push(@bug_status, @closed);
    }

    @bug_status = uniq @bug_status;
    my $all = grep { $_ eq "__all__" } @bug_status;
    # This will also handle removing __open__ and __closed__ for us
    # (__all__ too, which is why we check for it above, first).
    @bug_status = _valid_values(\@bug_status, $legal_statuses);

    # If the user has selected every status, change to selecting none.
    # This is functionally equivalent, but quite a lot faster.    
    if ($all or scalar(@bug_status) == scalar(@$legal_statuses)) {
        delete $params->{'bug_status'};
    }
    else {
        $params->{'bug_status'} = \@bug_status;
    }
}

sub _special_parse_chfield {
    my ($self) = @_;
    my $params = $self->_params;
    
    my $date_from = trim(lc($params->{'chfieldfrom'} || ''));
    my $date_to = trim(lc($params->{'chfieldto'} || ''));
    $date_from = '' if $date_from eq 'now';
    $date_to = '' if $date_to eq 'now';
    my @fields = $self->_param_array('chfield');
    my $value_to = $params->{'chfieldvalue'};
    $value_to = '' if !defined $value_to;

    my @charts;
    # It is always safe and useful to push delta_ts into the charts
    # if there are any dates specified. It doesn't conflict with
    # searching [Bug creation], because a bug's delta_ts is set to
    # its creation_ts when it is created. So this just gives the
    # database an additional index to possibly choose.
    if ($date_from ne '') {
        push(@charts, ['delta_ts', 'greaterthaneq', $date_from]);
    }
    if ($date_to ne '') {
        push(@charts, ['delta_ts', 'lessthaneq', $date_to]);
    }
    
    if (grep { $_ eq '[Bug creation]' } @fields) {
        if ($date_from ne '') {
            push(@charts, ['creation_ts', 'greaterthaneq', $date_from]);
        }
        if ($date_to ne '') {
            push(@charts, ['creation_ts', 'lessthaneq', $date_to]);
        }
    }

    # Basically, we construct the chart like:
    #
    # (added_for_field1 = value OR added_for_field2 = value)
    # AND (date_field1_changed >= date_from OR date_field2_changed >= date_from)
    # AND (date_field1_changed <= date_to OR date_field2_changed <= date_to)
    #
    # Theoretically, all we *really* would need to do is look for the field id
    # in the bugs_activity table, because we've already limited the search
    # by delta_ts above, but there's no chart to do that, so we check the
    # change date of the fields.
    
    if ($value_to ne '') {
        my @value_chart;
        foreach my $field (@fields) {
            next if $field eq '[Bug creation]';
            push(@value_chart, $field, 'changedto', $value_to);
        }
        push(@charts, \@value_chart) if @value_chart;
    }

    if ($date_from ne '') {
        my @date_from_chart;
        foreach my $field (@fields) {
            next if $field eq '[Bug creation]';
            push(@date_from_chart, $field, 'changedafter', $date_from);
        }
        push(@charts, \@date_from_chart) if @date_from_chart;
    }
    if ($date_to ne '') {
        my @date_to_chart;
        foreach my $field (@fields) {
            push(@date_to_chart, $field, 'changedbefore', $date_to);
        }
        push(@charts, \@date_to_chart) if @date_to_chart;
    }

    return @charts;
}

sub _special_parse_deadline {
    my ($self) = @_;
    return if !$self->_user->is_timetracker;
    my $params = $self->_params;
    
    my @charts;
    if (my $from = $params->{'deadlinefrom'}) {
        push(@charts, ['deadline', 'greaterthaneq', $from]);
    }
    if (my $to = $params->{'deadlineto'}) {
        push(@charts, ['deadline', 'lessthaneq', $to]);
    }
    
    return @charts;
}

sub _special_parse_email {
    my ($self) = @_;
    my $params = $self->_params;
    
    my @email_params = grep { $_ =~ /^email\d+$/ } keys %$params;
    
    my @charts;
    foreach my $param (@email_params) {
        $param =~ /(\d+)$/;
        my $id = $1;
        my $email = trim($params->{"email$id"});
        next if !$email;
        my $type = $params->{"emailtype$id"} || 'anyexact';
        $type = "anyexact" if $type eq "exact";

        my @or_charts;
        foreach my $field qw(assigned_to reporter cc qa_contact) {
            if ($params->{"email$field$id"}) {
                push(@or_charts, $field, $type, $email);
            }
        }
        if ($params->{"emaillongdesc$id"}) {
            push(@or_charts, "commenter", $type, $email);
        }

        push(@charts, \@or_charts);
    }
    
    return @charts;
}

sub _special_parse_resolution {
    my ($self) = @_;
    my $params = $self->_params;
    return if !defined $params->{'resolution'};

    my @resolution = $self->_param_array('resolution');
    my $legal_resolutions = $self->_chart_fields->{resolution}->legal_values;
    @resolution = _valid_values(\@resolution, $legal_resolutions, '---');
    if (scalar(@resolution) == scalar(@$legal_resolutions)) {
        delete $params->{'resolution'};
    }
}

sub _valid_values {
    my ($input, $valid, $extra_value) = @_;
    my @result;
    foreach my $item (@$input) {
        if (defined $extra_value and $item eq $extra_value) {
            push(@result, $item);
        }
        elsif (grep { $_->name eq $item } @$valid) {
            push(@result, $item);
        }
    }
    return @result;
}

######################################
# Internal Accessors: Boolean Charts #
######################################

sub _charts_to_conditions {
    my ($self) = @_;
    my @charts = $self->_params_to_charts();
    
    my (@joins, @having, @where_terms);
    
    foreach my $chart (@charts) {
        my @and_terms;
        my $negate;
        foreach my $and_item (@$chart) {
            if (!ref $and_item and $and_item eq NEGATE) {
                $negate = 1;
                next;
            }
            my @or_terms;
            foreach my $or_item (@$and_item) {
                if ($or_item->{term} ne '') {
                    push(@or_terms, $or_item->{term});
                }
                push(@joins, @{ $or_item->{joins} });
                push(@having, @{ $or_item->{having} });
            }

            if (@or_terms) {
                # If a term contains ANDs, we need to put parens around the
                # condition. This is a pretty weak test, but it's actually OK
                # to put parens around too many things.
                @or_terms = map { $_ =~ /\bAND\b/i ? "($_)" : $_ } @or_terms;
                my $or_sql = join(' OR ', @or_terms);
                push(@and_terms, $or_sql);
            }
        }
        # And here we need to paren terms that contain ORs.
        @and_terms = map { $_ =~ /\bOR\b/i ? "($_)" : $_ } @and_terms;
        my $and_sql = join(' AND ', @and_terms);
        if ($negate and $and_sql ne '') {
            $and_sql = "NOT ($and_sql)";
        }
        push(@where_terms, $and_sql) if $and_sql ne '';
    }

    return (\@joins, \@having, \@where_terms);
}

sub _params_to_charts {
    my ($self) = @_;
    my $params = $self->_params;
    $self->_convert_special_params_to_chart_params();
    my @param_list = keys %$params;
    
    my @all_field_params = grep { /^field-?\d+/ } @param_list;
    my @chart_ids = map { /^field(-?\d+)/; $1 } @all_field_params;
    @chart_ids = sort { $a <=> $b } uniq @chart_ids;
    
    my $sequence = 0;
    my @charts;
    foreach my $chart_id (@chart_ids) {
        my @all_and = grep { /^field$chart_id-\d+/ } @param_list;
        my @and_ids = map { /^field$chart_id-(\d+)/; $1 } @all_and;
        @and_ids = sort { $a <=> $b } uniq @and_ids;
        
        my @and_charts;
        foreach my $and_id (@and_ids) {
            my @all_or = grep { /^field$chart_id-$and_id-\d+/ } @param_list;
            my @or_ids = map { /^field$chart_id-$and_id-(\d+)/; $1 } @all_or;
            @or_ids = sort { $a <=> $b } uniq @or_ids;
            
            my @or_charts;
            foreach my $or_id (@or_ids) {
                my $info = $self->_handle_chart($chart_id, $and_id, $or_id);
                # $info will be undefined if _handle_chart returned early,
                # meaning that the field, value, or operator were empty.
                push(@or_charts, $info) if defined $info;
            }
            if ($params->{"negate$chart_id"}) {
                push(@and_charts, NEGATE);
            }
            push(@and_charts, \@or_charts);
        }
        push(@charts, \@and_charts);
    }
    
    return @charts;
}

sub _handle_chart {
    my ($self, $chart_id, $and_id, $or_id) = @_;
    my $dbh = Bugzilla->dbh;
    my $params = $self->_params;
    
    my $sql_chart_id = $chart_id;
    if ($chart_id < 0) {
        $sql_chart_id = "default_" . abs($chart_id);
    }
    
    my $identifier = "$chart_id-$and_id-$or_id";
    
    my $field = $params->{"field$identifier"};
    my $operator = $params->{"type$identifier"};
    my $value = $params->{"value$identifier"};

    return if (!defined $field or !defined $operator or !defined $value);

    my $string_value;
    if (ref $value eq 'ARRAY') {
        # Trim input and ignore blank values.
        @$value = map { trim($_) } @$value;
        @$value = grep { defined $_ and $_ ne '' } @$value;
        return if !@$value;
        $string_value = join(',', @$value);
    }
    else {
        return if $value eq '';
        $string_value = $value;
    }
    
    $self->_chart_fields->{$field}
        or ThrowCodeError("invalid_field_name", { field => $field });
    trick_taint($field);
    
    # This is the field as you'd reference it in a SQL statement.
    my $full_field = $field =~ /\./ ? $field : "bugs.$field";

    # "value" and "quoted" are for search functions that always operate
    # on a scalar string and never care if they were passed multiple
    # parameters. If the user does pass multiple parameters, they will
    # become a space-separated string for those search functions.
    #
    # all_values and all_quoted are for search functions that do operate
    # on multiple values, like anyexact.

    my %search_args = (
        chart_id   => $sql_chart_id,
        sequence   => $or_id,
        field      => $field,
        full_field => $full_field,
        operator   => $operator,
        value      => $string_value,
        quoted     => $dbh->quote($string_value),
        all_values => $value,
        joins      => [],
        having     => [],
    );
    # This should add a "term" selement to %search_args.
    $self->do_search_function(\%search_args);
    
    # All the things here that don't get pulled out of
    # %search_args are their original values before
    # do_search_function modified them.   
    $self->search_description({
        field => $field, type => $operator,
        value => $string_value, term => $search_args{term},
    });
    
    return \%search_args;
}
   
##################################
# do_search_function And Helpers #
##################################

# This takes information about the current boolean chart and translates
# it into SQL, using the constants at the top of this file.
sub do_search_function {
    my ($self, $args) = @_;
    my ($field, $operator) = @$args{qw(field operator)};
    
    my $actual_field = FIELD_MAP->{$field} || $field;
    $args->{field} = $actual_field;
    
    if (my $parse_func = SPECIAL_PARSING->{$actual_field}) {
        $self->$parse_func($args);
        # Some parsing functions set $term, though most do not.
        # For the ones that set $term, we don't need to do any further
        # parsing.
        return if $args->{term};
    }
    
    my $operator_field_override = $self->_get_operator_field_override();
    my $override = $operator_field_override->{$actual_field};
    if (!$override) {
        # Multi-select fields get special handling.
        if ($self->_multi_select_fields->{$actual_field}) {
            $override = $operator_field_override->{_multi_select};
        }
        # And so do attachment fields, if they don't have a specific
        # individual override.
        elsif ($actual_field =~ /^attachments\./) {
            $override = $operator_field_override->{attachments};
        }
    }
    
    if ($override) {
        my $search_func = $self->_pick_override_function($override, $operator);
        $self->$search_func($args) if $search_func;
    }

    # Some search functions set $term, and some don't. For the ones that
    # don't (or for fields that don't have overrides) we now call the
    # direct operator function from OPERATORS.
    if (!defined $args->{term}) {
        $self->_do_operator_function($args);
    }
    
    if (!defined $args->{term}) {
        # This field and this type don't work together. Generally,
        # this should never be reached, because it should be handled
        # explicitly by OPERATOR_FIELD_OVERRIDE.
        ThrowUserError("search_field_operator_invalid",
                       { field => $field, operator => $operator });
    }
}

# A helper for various search functions that need to run operator
# functions directly.
sub _do_operator_function {
    my ($self, $func_args) = @_;
    my $operator = $func_args->{operator};
    my $operator_func = OPERATORS->{$operator};
    $self->$operator_func($func_args);
}

sub _reverse_operator {
    my ($self, $operator) = @_;
    my $reverse = OPERATOR_REVERSE->{$operator};
    return $reverse if $reverse;
    if ($operator =~ s/^not//) {
        return $operator;
    }
    return "not$operator";
}

sub _pick_override_function {
    my ($self, $override, $operator) = @_;
    my $search_func = $override->{$operator};

    if (!$search_func) {
        # If we don't find an override for one specific operator,
        # then there are some special override types:
        # _non_changed: For any operator that doesn't have the word
        #               "changed" in it
        # _default: Overrides all operators that aren't explicitly specified.
        if ($override->{_non_changed} and $operator !~ /changed/) {
            $search_func = $override->{_non_changed};
        }
        elsif ($override->{_default}) {
            $search_func = $override->{_default};
        }
    }

    return $search_func;
}

sub _get_operator_field_override {
    my $self = shift;
    my $cache = Bugzilla->request_cache;

    return $cache->{operator_field_override} 
        if defined $cache->{operator_field_override};

    my %operator_field_override = %{ OPERATOR_FIELD_OVERRIDE() };
    Bugzilla::Hook::process('search_operator_field_override',
                            { search => $self, 
                              operators => \%operator_field_override });

    $cache->{operator_field_override} = \%operator_field_override;
    return $cache->{operator_field_override};
}

###########################
# Search Function Helpers #
###########################

sub build_subselect {
    my ($outer, $inner, $table, $cond) = @_;
    return "$outer IN (SELECT $inner FROM $table WHERE $cond)";
}

# Used by anyexact to get the list of input values. This allows us to
# support values with commas inside of them in the standard charts, and
# still accept string values for the boolean charts (and split them on
# commas).
sub _all_values {
    my ($self, $args, $split_on) = @_;
    $split_on ||= qr/[\s,]+/;
    my $dbh = Bugzilla->dbh;
    my $all_values = $args->{all_values};
    
    my @array;
    if (ref $all_values eq 'ARRAY') {
        @array = @$all_values;
    }
    else {
        @array = split($split_on, $all_values);
        @array = map { trim($_) } @array;
        @array = grep { defined $_ and $_ ne '' } @array;
    }
    
    if ($args->{field} eq 'resolution') {
        @array = map { $_ eq '---' ? '' : $_ } @array;
    }
    
    return @array;
}

# Support for "any/all/nowordssubstr" comparison type ("words as substrings")
sub _substring_terms {
    my ($self, $args) = @_;
    my $dbh = Bugzilla->dbh;

    # We don't have to (or want to) use _all_values, because we'd just
    # split each term on spaces and commas anyway.
    my @words = split(/[\s,]+/, $args->{value});
    @words = grep { defined $_ and $_ ne '' } @words;
    @words = map { $dbh->quote($_) } @words;
    my @terms = map { $dbh->sql_iposition($_, $args->{full_field}) . " > 0" }
                    @words;
    return @terms;
}

sub _word_terms {
    my ($self, $args) = @_;
    my $dbh = Bugzilla->dbh;
    
    my @values = split(/[\s,]+/, $args->{value});
    @values = grep { defined $_ and $_ ne '' } @values;
    my @substring_terms = $self->_substring_terms($args);
    
    my @terms;
    my $start = $dbh->WORD_START;
    my $end   = $dbh->WORD_END;
    foreach my $word (@values) {
        my $regex  = $start . quotemeta($word) . $end;
        my $quoted = $dbh->quote($regex);
        # We don't have to check the regexp, because we escaped it, so we're
        # sure it's valid.
        my $regex_term = $dbh->sql_regexp($args->{full_field}, $quoted,
                                          'no check');
        # Regular expressions are slow--substring searches are faster.
        # If we're searching for a word, we're also certain that the
        # substring will appear in the value. So we limit first by
        # substring and then by a regex that will match just words.
        my $substring_term = shift @substring_terms;
        push(@terms, "$substring_term AND $regex_term");
    }
    
    return @terms;
}

#####################################
# "Special Parsing" Functions: Date #
#####################################

sub _timestamp_translate {
    my ($self, $args) = @_;
    my $value = $args->{value};
    my $dbh = Bugzilla->dbh;

    return if $value !~ /^[\+\-]?\d+[hdwmy]$/i;
    
    $args->{value}  = SqlifyDate($value);
    $args->{quoted} = $dbh->quote($args->{value});
}

sub SqlifyDate {
    my ($str) = @_;
    $str = "" if !defined $str;
    if ($str eq "") {
        my ($sec, $min, $hour, $mday, $month, $year, $wday) = localtime(time());
        return sprintf("%4d-%02d-%02d 00:00:00", $year+1900, $month+1, $mday);
    }

    if ($str =~ /^(-|\+)?(\d+)([hHdDwWmMyY])$/) {   # relative date
        my ($sign, $amount, $unit, $date) = ($1, $2, lc $3, time);
        my ($sec, $min, $hour, $mday, $month, $year, $wday)  = localtime($date);
        if ($sign && $sign eq '+') { $amount = -$amount; }
        if ($unit eq 'w') {                  # convert weeks to days
            $amount = 7*$amount + $wday;
            $unit = 'd';
        }
        if ($unit eq 'd') {
            $date -= $sec + 60*$min + 3600*$hour + 24*3600*$amount;
            return time2str("%Y-%m-%d %H:%M:%S", $date);
        }
        elsif ($unit eq 'y') {
            return sprintf("%4d-01-01 00:00:00", $year+1900-$amount);
        }
        elsif ($unit eq 'm') {
            $month -= $amount;
            while ($month<0) { $year--; $month += 12; }
            return sprintf("%4d-%02d-01 00:00:00", $year+1900, $month+1);
        }
        elsif ($unit eq 'h') {
            # Special case 0h for 'beginning of this hour'
            if ($amount == 0) {
                $date -= $sec + 60*$min;
            } else {
                $date -= 3600*$amount;
            }
            return time2str("%Y-%m-%d %H:%M:%S", $date);
        }
        return undef;                      # should not happen due to regexp at top
    }
    my $date = str2time($str);
    if (!defined($date)) {
        ThrowUserError("illegal_date", { date => $str });
    }
    return time2str("%Y-%m-%d %H:%M:%S", $date);
}

######################################
# "Special Parsing" Functions: Users #
######################################

sub pronoun {
    my ($noun, $user) = (@_);
    if ($noun eq "%user%") {
        if ($user->id) {
            return $user->id;
        } else {
            ThrowUserError('login_required_for_pronoun');
        }
    }
    if ($noun eq "%reporter%") {
        return "bugs.reporter";
    }
    if ($noun eq "%assignee%") {
        return "bugs.assigned_to";
    }
    if ($noun eq "%qacontact%") {
        return "COALESCE(bugs.qa_contact,0)";
    }
    return 0;
}

sub _contact_pronoun {
    my ($self, $args) = @_;
    my $value = $args->{value};
    my $user = $self->_user;
    
    if ($value =~ /^\%group/) {
        $self->_contact_exact_group($args);
    }
    elsif ($value =~ /^(%\w+%)$/) {
        $args->{value} = pronoun($1, $user);
        $args->{quoted} = $args->{value};
        $args->{value_is_id} = 1;
    }
}

sub _contact_exact_group {
    my ($self, $args) = @_;
    my ($value, $operator, $field, $chart_id, $joins) =
        @$args{qw(value operator field chart_id joins)};
    my $dbh = Bugzilla->dbh;
    my $user = $self->_user;
    
    $value =~ /\%group\.([^%]+)%/;
    my $group = Bugzilla::Group->check($1);
    $group->check_members_are_visible();
    $user->in_group($group)
      || ThrowUserError('invalid_group_name', {name => $group->name});

    my $group_ids = Bugzilla::Group->flatten_group_membership($group->id);
    my $table = "user_group_map_$chart_id";
    my $join = {
        table => 'user_group_map',
        as    => $table,
        from  => $field,
        to    => 'user_id',
        extra => [$dbh->sql_in("$table.group_id", $group_ids),
                  "$table.isbless = 0"],
    };
    push(@$joins, $join);
    if ($operator =~ /^not/) {
        $args->{term} = "$table.group_id IS NULL";
    }
    else {
        $args->{term} = "$table.group_id IS NOT NULL";
    }
}

sub _cc_pronoun {
    my ($self, $args) = @_;
    my ($full_field, $value) = @$args{qw(full_field value)};
    my $user = $self->_user;

    if ($value =~ /\%group/) {
        return $self->_cc_exact_group($args);
    }
    elsif ($value =~ /^(%\w+%)$/) {
        $args->{value} = pronoun($1, $user);
        $args->{quoted} = $args->{value};
        $args->{value_is_id} = 1;
    }
}

sub _cc_exact_group {
    my ($self, $args) = @_;
    my ($chart_id, $sequence, $joins, $operator, $value) =
        @$args{qw(chart_id sequence joins operator value)};
    my $user = $self->_user;
    my $dbh = Bugzilla->dbh;
    
    $value =~ m/%group\.([^%]+)%/;
    my $group = Bugzilla::Group->check($1);
    $group->check_members_are_visible();
    $user->in_group($group)
      || ThrowUserError('invalid_group_name', {name => $group->name});

    my $all_groups = Bugzilla::Group->flatten_group_membership($group->id);

    # This is for the email1, email2, email3 fields from query.cgi.
    if ($chart_id eq "") {
        $chart_id = "CC$$sequence";
        $args->{sequence}++;
    }
    
    my $cc_table = "cc_$chart_id";
    push(@$joins, { table => 'cc', as => $cc_table });
    my $group_table = "user_group_map_$chart_id";
    my $group_join = {
        table => 'user_group_map',
        as    => $group_table,
        from  => "$cc_table.who",
        to    => 'user_id',
        extra => [$dbh->sql_in("$group_table.group_id", $all_groups),
                  "$group_table.isbless = 0"],
    };
    push(@$joins, $group_join);

    if ($operator =~ /^not/) {
        $args->{term} = "$group_table.group_id IS NULL";
    }
    else {
        $args->{term} = "$group_table.group_id IS NOT NULL";
    }
}

# XXX This should probably be merged with cc_pronoun.
sub _commenter_pronoun {
    my ($self, $args) = @_;
    my $value = $args->{value};
    my $user = $self->_user;

    if ($value =~ /^(%\w+%)$/) {
        $args->{value} = pronoun($1, $user);
        $args->{quoted} = $args->{value};
        $args->{value_is_id} = 1;
    }
}

#####################################################################
# Search Functions
#####################################################################

sub _invalid_combination {
    my ($self, $args) = @_;
    my ($field, $operator) = @$args{qw(field operator)};
    ThrowUserError('search_field_operator_invalid',
                   { field => $field, operator => $operator });
}

# For all the "user" fields--assigned_to, reporter, qa_contact,
# cc, commenter, requestee, etc.
sub _user_nonchanged {
    my ($self, $args) = @_;
    my ($field, $operator, $chart_id, $sequence, $joins) =
        @$args{qw(field operator chart_id sequence joins)};

    my $is_in_other_table;
    if (my $join = USER_FIELDS->{$field}->{join}) {
        $is_in_other_table = 1;
        my $as = "${field}_$chart_id";
        # Needed for setters.login_name and requestees.login_name.
        # Otherwise when we try to join "profiles" below, we'd get
        # something like "setters.login_name.login_name" in the "from".
        $as =~ s/\./_/g;        
        # This helps implement the email1, email2, etc. parameters.
        if ($chart_id =~ /default/) {
            $as .= "_$sequence";
        }
        my $isprivate = USER_FIELDS->{$field}->{isprivate};
        my $extra = ($isprivate and !$self->_user->is_insider)
                    ? ["$as.isprivate = 0"] : [];
        # We want to copy $join so as not to modify USER_FIELDS.
        push(@$joins, { %$join, as => $as, extra => $extra });
        my $search_field = USER_FIELDS->{$field}->{field};
        $args->{full_field} = "$as.$search_field";
    }

    my $is_nullable = USER_FIELDS->{$field}->{nullable};
    my $null_alternate = "''";
    # When using a pronoun, we use the userid, and we don't have to
    # join the profiles table.
    if ($args->{value_is_id}) {
        $null_alternate = 0;
    }
    else {
        my $as = "name_${field}_$chart_id";
        # For fields with periods in their name.
        $as =~ s/\./_/;
        my $join = {
            table => 'profiles',
            as    => $as,
            from  => $args->{full_field},
            to    => 'userid',
            join  => (!$is_in_other_table and !$is_nullable) ? 'INNER' : undef,
        };
        push(@$joins, $join);
        $args->{full_field} = "$as.login_name";
    }
    
    # We COALESCE fields that can be NULL, to make "not"-style operators
    # continue to work properly. For example, "qa_contact is not equal to bob"
    # should also show bugs where the qa_contact is NULL. With COALESCE,
    # it does.
    if ($is_nullable) {
        $args->{full_field} = "COALESCE($args->{full_field}, $null_alternate)";
    }
    
    # For fields whose values are stored in other tables, negation (NOT)
    # only works properly if we put the condition into the JOIN instead
    # of the WHERE.
    if ($is_in_other_table) {
        # Using the last join works properly whether we're searching based
        # on userid or login_name.
        my $last_join = $joins->[-1];
        
        # For negative operators, the system we're using here
        # only works properly if we reverse the operator and check IS NULL
        # in the WHERE.
        my $is_negative = $operator =~ /^no/ ? 1 : 0;
        if ($is_negative) {
            $args->{operator} = $self->_reverse_operator($operator);
        }
        $self->_do_operator_function($args);
        push(@{ $last_join->{extra} }, $args->{term});
        
        # For login_name searches, we only want a single join.
        # So we create a subselect table out of our two joins. This makes
        # negation (NOT) work properly for values that are in other
        # tables.
        if ($last_join->{table} eq 'profiles') {
            pop @$joins;
            $last_join->{join} = 'INNER';
            my ($join_sql) = $self->_translate_join($last_join);
            my $first_join = $joins->[-1];
            my $as = $first_join->{as};            
            my $table = $first_join->{table};
            my $columns = "bug_id";
            $columns .= ",isprivate" if @{ $first_join->{extra} };
            my $new_table = "SELECT $columns FROM $table AS $as $join_sql";
            $first_join->{table} = "($new_table)";
            # We always want to LEFT JOIN the generated table.
            delete $first_join->{join};
            # To support OR charts, we need multiple tables.
            my $new_as = $first_join->{as} . "_$sequence";
            $_ =~ s/\Q$as\E/$new_as/ foreach @{ $first_join->{extra} };
            $first_join->{as} = $new_as;
            $last_join = $first_join;
        }
        
        # If we're joining the first table (we're using a pronoun and
        # searching by user id) then we need to check $other_table->{field}.
        my $check_field = $last_join->{as} . '.bug_id';
        if ($is_negative) {
            $args->{term} = "$check_field IS NULL";
        }
        else {
            $args->{term} = "$check_field IS NOT NULL";
        }
    }
}

# XXX This duplicates having Commenter as a search field.
sub _long_desc_changedby {
    my ($self, $args) = @_;
    my ($chart_id, $joins, $value) = @$args{qw(chart_id joins value)};
    
    my $table = "longdescs_$chart_id";
    push(@$joins, { table => 'longdescs', as => $table });
    my $user_id = login_to_id($value, THROW_ERROR);
    $args->{term} = "$table.who = $user_id";
}

sub _long_desc_changedbefore_after {
    my ($self, $args) = @_;
    my ($chart_id, $operator, $value, $joins) =
        @$args{qw(chart_id operator value joins)};
    my $dbh = Bugzilla->dbh;
    
    my $sql_operator = ($operator =~ /before/) ? '<=' : '>=';
    my $table = "longdescs_$chart_id";
    my $sql_date = $dbh->quote(SqlifyDate($value));
    my $join = {
        table => 'longdescs',
        as    => $table,
        extra => ["$table.bug_when $sql_operator $sql_date"],
    };
    push(@$joins, $join);
    $args->{term} = "$table.bug_when IS NOT NULL";
}

sub _content_matches {
    my ($self, $args) = @_;
    my ($chart_id, $joins, $fields, $operator, $value) =
        @$args{qw(chart_id joins fields operator value)};
    my $dbh = Bugzilla->dbh;
    
    # "content" is an alias for columns containing text for which we
    # can search a full-text index and retrieve results by relevance, 
    # currently just bug comments (and summaries to some degree).
    # There's only one way to search a full-text index, so we only
    # accept the "matches" operator, which is specific to full-text
    # index searches.

    # Add the fulltext table to the query so we can search on it.
    my $table = "bugs_fulltext_$chart_id";
    my $comments_col = "comments";
    $comments_col = "comments_noprivate" unless $self->_user->is_insider;
    push(@$joins, { table => 'bugs_fulltext', as => $table });
    
    # Create search terms to add to the SELECT and WHERE clauses.
    my ($term1, $rterm1) =
        $dbh->sql_fulltext_search("$table.$comments_col", $value, 1);
    my ($term2, $rterm2) =
        $dbh->sql_fulltext_search("$table.short_desc", $value, 2);
    $rterm1 = $term1 if !$rterm1;
    $rterm2 = $term2 if !$rterm2;

    # The term to use in the WHERE clause.
    my $term = "$term1 > 0 OR $term2 > 0";
    if ($operator =~ /not/i) {
        $term = "NOT($term)";
    }
    $args->{term} = $term;
    
    # In order to sort by relevance (in case the user requests it),
    # we SELECT the relevance value so we can add it to the ORDER BY
    # clause. Every time a new fulltext chart isadded, this adds more 
    # terms to the relevance sql.
    #
    # We build the relevance SQL by modifying the COLUMNS list directly,
    # which is kind of a hack but works.
    my $current = COLUMNS->{'relevance'}->{name};
    $current = $current ? "$current + " : '';
    # For NOT searches, we just add 0 to the relevance.
    my $select_term = $operator =~ /not/ ? 0 : "($current$rterm1 + $rterm2)";
    COLUMNS->{'relevance'}->{name} = $select_term;
}

sub _long_desc {
    my ($self, $args) = @_;
    my ($chart_id, $joins) = @$args{qw(chart_id joins)};
    
    my $table = "longdescs_$chart_id";
    my $extra = $self->_user->is_insider ? [] : ["$table.isprivate = 0"];
    my $join = {
        table => 'longdescs',
        as    => $table,
        extra => $extra,
    };
    push(@$joins, $join);
    $args->{full_field} = "$table.thetext";
}

sub _longdescs_isprivate {
    my ($self, $args) = @_;
    my ($chart_id, $joins) = @$args{qw(chart_id joins)};
    
    my $table = "longdescs_$chart_id";
    my $extra = $self->_user->is_insider ? [] : ["$table.isprivate = 0"];
    my $join = {
        table => 'longdescs',
        as    => $table,
        extra => $extra,
    };
    push(@$joins, $join);
    $args->{full_field} = "$table.isprivate";
}

sub _work_time_changedby {
    my ($self, $args) = @_;
    my ($chart_id, $joins, $value) = @$args{qw(chart_id joins value)};
    
    my $table = "longdescs_$chart_id";
    push(@$joins, { table => 'longdescs', as => $table });
    my $user_id = login_to_id($value, THROW_ERROR);
    $args->{term} = "$table.who = $user_id AND $table.work_time != 0";
}

sub _work_time_changedbefore_after {
    my ($self, $args) = @_;
    my ($chart_id, $operator, $value, $joins) =
        @$args{qw(chart_id operator value joins)};
    my $dbh = Bugzilla->dbh;
    
    my $table = "longdescs_$chart_id";
    my $sql_operator = ($operator =~ /before/) ? '<=' : '>=';
    my $sql_date = $dbh->quote(SqlifyDate($value));
    my $join = {
        table => 'longdescs',
        as    => $table,
        extra => ["$table.work_time != 0",
                  "$table.bug_when $sql_operator $sql_date"],
    };
    push(@$joins, $join);
    
    $args->{term} = "$table.bug_when IS NOT NULL";
}

sub _work_time {
    my ($self, $args) = @_;
    my ($chart_id, $joins) = @$args{qw(chart_id joins)};
    
    my $table = "longdescs_$chart_id";
    push(@$joins, { table => 'longdescs', as => $table });
    $args->{full_field} = "$table.work_time";
}

sub _percentage_complete {
    my ($self, $args) = @_;
    my ($chart_id, $joins, $operator, $having, $fields) =
        @$args{qw(chart_id joins operator having fields)};

    my $table = "longdescs_$chart_id";

    # We can't just use "percentage_complete" as the field, because
    # (a) PostgreSQL doesn't accept it in the HAVING clause
    # and (b) it wouldn't work in multiple chart rows, because it uses
    # a fixed name for the table, "ldtime".
    my $expression = COLUMNS->{percentage_complete}->{name};
    $expression =~ s/\bldtime\b/$table/g;
    $args->{full_field} = "($expression)";
    push(@$joins, { table => 'longdescs', as => $table });

    # We need remaining_time in _select_columns, otherwise we can't use
    # it in the expression for creating percentage_complete.
    $self->_add_extra_column('remaining_time');

    $self->_do_operator_function($args);
    push(@$having, $args->{term});
   
    # We put something into $args->{term} so that do_search_function
    # stops processing.
    $args->{term} = '';
}

sub _bug_group_nonchanged {
    my ($self, $args) = @_;
    my ($chart_id, $joins, $field) = @$args{qw(chart_id joins field)};
    
    my $map_table = "bug_group_map_$chart_id";
    
    push(@$joins, { table => 'bug_group_map', as => $map_table });
    
    my $groups_table = "groups_$chart_id";
    my $full_field = "$groups_table.name";
    $args->{full_field} = $full_field;
    $self->_do_operator_function($args);
    my $term = $args->{term};
    my $groups_join = {
        table => 'groups',
        as    => $groups_table,
        from  => "$map_table.group_id",
        to    => 'id',
        extra => [$term],
    };
    push(@$joins, $groups_join);
    $args->{term} = "$full_field IS NOT NULL";
}

sub _attach_data_thedata {
    my ($self, $args) = @_;
    my ($chart_id, $joins) = @$args{qw(chart_id joins)};
    
    my $attach_table = "attachments_$chart_id";
    my $data_table = "attachdata_$chart_id";
    my $extra = $self->_user->is_insider
                ? [] : ["$attach_table.isprivate = 0"];
    my $attachments_join = {
        table => 'attachments',
        as    => $attach_table,
        extra => $extra,
    };
    my $data_join = {
        table => 'attach_data',
        as    => $data_table,
        from  => "$attach_table.attach_id",
        to    => "id",
    };
    push(@$joins, $attachments_join, $data_join);
    $args->{full_field} = "$data_table.thedata";
}

sub _attachments {
    my ($self, $args) = @_;
    my ($chart_id, $joins, $field) =
        @$args{qw(chart_id joins field)};
    my $dbh = Bugzilla->dbh;
    
    my $table = "attachments_$chart_id";
    my $extra = $self->_user->is_insider ? [] : ["$table.isprivate = 0"];
    my $join = {
        table => 'attachments',
        as    => $table,
        extra => $extra,
    };
    push(@$joins, $join);
    $field =~ /^attachments\.(.+)$/;
    my $attach_field = $1;
    
    $args->{full_field} = "$table.$attach_field";
}

sub _join_flag_tables {
    my ($self, $args) = @_;
    my ($joins, $chart_id) = @$args{qw(joins chart_id)};
    
    my $attach_table = "attachments_$chart_id";
    my $flags_table = "flags_$chart_id";
    my $extra = $self->_user->is_insider
                ? [] : ["$attach_table.isprivate = 0"];
    my $attachments_join = {
        table => 'attachments',
        as    => $attach_table,
        extra => $extra,
    };
    # We join both the bugs and the attachments table in separately,
    # and then the join code will later combine the terms.
    my $flags_join = {
        table => 'flags',
        as    => $flags_table,
        extra => ["($flags_table.attach_id = $attach_table.attach_id "
                  . " OR $flags_table.attach_id IS NULL)"],
    };
    
    push(@$joins, $attachments_join, $flags_join);
}

sub _flagtypes_name {
    my ($self, $args) = @_;
    my ($chart_id, $operator, $joins, $field, $having) = 
        @$args{qw(chart_id operator joins field having)};
    my $dbh = Bugzilla->dbh;
    
    # Matches bugs by flag name/status.
    # Note that--for the purposes of querying--a flag comprises
    # its name plus its status (i.e. a flag named "review" 
    # with a status of "+" can be found by searching for "review+").
    
    # Don't do anything if this condition is about changes to flags,
    # as the generic change condition processors can handle those.
    return if $operator =~ /^changed/;
    
    # Add the flags and flagtypes tables to the query.  We do 
    # a left join here so bugs without any flags still match 
    # negative conditions (f.e. "flag isn't review+").
    $self->_join_flag_tables($args);
    my $flags = "flags_$chart_id";
    my $flagtypes = "flagtypes_$chart_id";
    my $flagtypes_join = {
        table => 'flagtypes',
        as    => $flagtypes,
        from  => "$flags.type_id",
        to    => 'id',
    };
    push(@$joins, $flagtypes_join);
    
    my $full_field = $dbh->sql_string_concat("$flagtypes.name",
                                             "$flags.status");
    $args->{full_field} = $full_field;
    $self->_do_operator_function($args);
    my $term = $args->{term};
    
    # If this is a negative condition (f.e. flag isn't "review+"),
    # we only want bugs where all flags match the condition, not 
    # those where any flag matches, which needs special magic.
    # Instead of adding the condition to the WHERE clause, we select
    # the number of flags matching the condition and the total number
    # of flags on each bug, then compare them in a HAVING clause.
    # If the numbers are the same, all flags match the condition,
    # so this bug should be included.
    if ($operator =~ /^not/) {
       push(@$having,
            "SUM(CASE WHEN $full_field IS NOT NULL THEN 1 ELSE 0 END) = " .
            "SUM(CASE WHEN $term THEN 1 ELSE 0 END)");
       $args->{term} = '';
    }
}

sub _days_elapsed {
    my ($self, $args) = @_;
    my $dbh = Bugzilla->dbh;
    
    $args->{full_field} = "(" . $dbh->sql_to_days('NOW()') . " - " .
                                $dbh->sql_to_days('bugs.delta_ts') . ")";
}

sub _component_nonchanged {
    my ($self, $args) = @_;
    
    $args->{full_field} = "components.name";
    $self->_do_operator_function($args);
    my $term = $args->{term};
    $args->{term} = build_subselect("bugs.component_id",
        "components.id", "components", $args->{term});
}

sub _product_nonchanged {
    my ($self, $args) = @_;
    
    # Generate the restriction condition
    $args->{full_field} = "products.name";
    $self->_do_operator_function($args);
    my $term = $args->{term};
    $args->{term} = build_subselect("bugs.product_id",
        "products.id", "products", $term);
}

sub _classification_nonchanged {
    my ($self, $args) = @_;
    my $joins = $args->{joins};
    
    # This joins the right tables for us.
    $self->_add_extra_column('product');
    
    # Generate the restriction condition    
    $args->{full_field} = "classifications.name";
    $self->_do_operator_function($args);
    my $term = $args->{term};
    $args->{term} = build_subselect("map_product.classification_id",
        "classifications.id", "classifications", $term);
}

sub _keywords_exact {
    my ($self, $args) = @_;
    my ($chart_id, $joins, $value, $operator) =
        @$args{qw(chart_id joins value operator)};
    my $dbh = Bugzilla->dbh;
    
    my @keyword_ids;
    foreach my $word (split(/[\s,]+/, $value)) {
        next if $word eq '';
        my $keyword = Bugzilla::Keyword->check($word);
        push(@keyword_ids, $keyword->id);
    }
    
    # XXX We probably should instead throw an error here if there were
    # just commas in the field.
    if (!@keyword_ids) {
        $args->{term} = '';
        return;
    }
    
    # This is an optimization for anywords and anyexact, since we already know
    # the keyword id from having checked it above.
    if ($operator eq 'anywords' or $operator eq 'anyexact') {
        my $table = "keywords_$chart_id";
        $args->{term} = $dbh->sql_in("$table.keywordid", \@keyword_ids);
        push(@$joins, { table => 'keywords', as => $table });
        return;
    }
    
    $self->_keywords_nonchanged($args);
}

sub _keywords_nonchanged {
    my ($self, $args) = @_;
    my ($chart_id, $joins) =
        @$args{qw(chart_id joins)};

    my $k_table = "keywords_$chart_id";
    my $kd_table = "keyworddefs_$chart_id";
    
    push(@$joins, { table => 'keywords', as => $k_table });
    my $defs_join = {
        table => 'keyworddefs',
        as    => $kd_table,
        from  => "$k_table.keywordid",
        to    => 'id',
    };
    push(@$joins, $defs_join);
    
    $args->{full_field} = "$kd_table.name";
}

# XXX This should be combined with blocked_nonchanged.
sub _dependson_nonchanged {
    my ($self, $args) = @_;
    my ($chart_id, $joins, $field, $operator) =
        @$args{qw(chart_id joins field operator)};
    
    my $table = "dependson_$chart_id";
    my $full_field = "$table.$field";
    $args->{full_field} = $full_field;
    $self->_do_operator_function($args);
    my $term = $args->{term};
    my $dep_join = {
        table => 'dependencies',
        as    => $table,
        to    => 'blocked',
        extra => [$term],
    };
    push(@$joins, $dep_join);
    $args->{term} = "$full_field IS NOT NULL";
}

sub _blocked_nonchanged {
    my ($self, $args) = @_;
    my ($chart_id, $joins, $field, $operator) =
        @$args{qw(chart_id joins field operator)};

    my $table = "blocked_$chart_id";
    my $full_field = "$table.$field";
    $args->{full_field} = $full_field;
    $self->_do_operator_function($args);
    my $term = $args->{term};
    my $dep_join = {
        table => 'dependencies',
        as    => $table,
        to    => 'dependson',
        extra => [$term],
    };
    push(@$joins, $dep_join);
    $args->{term} = "$full_field IS NOT NULL";
}

sub _alias_nonchanged {
    my ($self, $args) = @_;
    $args->{full_field} = "COALESCE(bugs.alias, '')";
    $self->_do_operator_function($args);
}

sub _owner_idle_time_greater_less {
    my ($self, $args) = @_;
    my ($chart_id, $joins, $value, $operator) =
        @$args{qw(chart_id joins value operator)};
    my $dbh = Bugzilla->dbh;
    
    my $table = "idle_$chart_id";
    my $quoted = $dbh->quote(SqlifyDate($value));
    
    my $ld_table = "comment_$table";
    my $act_table = "activity_$table";    
    my $comments_join = {
        table => 'longdescs',
        as    => $ld_table,
        from  => 'assigned_to',
        to    => 'who',
        extra => ["$ld_table.bug_when > $quoted"],
    };
    my $activity_join = {
        table => 'bugs_activity',
        as    => $act_table,
        from  => 'assigned_to',
        to    => 'who',
        extra => ["$act_table.bug_when > $quoted"]
    };
    
    push(@$joins, $comments_join, $activity_join);
    
    if ($operator =~ /greater/) {
        $args->{term} =
            "$ld_table.who IS NULL AND $act_table.who IS NULL";
    } else {
         $args->{term} =
            "$ld_table.who IS NOT NULL OR $act_table.who IS NOT NULL";
    }
}

sub _multiselect_negative {
    my ($self, $args) = @_;
    my ($field, $operator) = @$args{qw(field operator)};

    my $table;
    if ($field eq 'keywords') {
        $table = "keywords LEFT JOIN keyworddefs"
                 . " ON keywords.keywordid = keyworddefs.id";
        $args->{full_field} = "keyworddefs.name";
    }
    else { 
        $table = "bug_$field";
        $args->{full_field} = "$table.value";
    }
    $args->{operator} = $self->_reverse_operator($operator);
    $self->_do_operator_function($args);
    my $term = $args->{term};
    $args->{term} =
        "bugs.bug_id NOT IN (SELECT bug_id FROM $table WHERE $term)";
}

sub _multiselect_multiple {
    my ($self, $args) = @_;
    my ($chart_id, $field, $operator, $value)
        = @$args{qw(chart_id field operator value)};
    my $dbh = Bugzilla->dbh;
    
    my $table = "bug_$field";
    $args->{full_field} = "$table.value";
    
    my @terms;
    foreach my $word (split(/[\s,]+/, $value)) {
        $args->{value} = $word;
        $args->{quoted} = $dbh->quote($value);
        $self->_do_operator_function($args);
        my $term = $args->{term};
        push(@terms, "bugs.bug_id IN (SELECT bug_id FROM $table WHERE $term)");
    }
    
    if ($operator eq 'anyexact') {
        $args->{term} = join(" OR ", @terms);
    }
    else {
        $args->{term} = join(" AND ", @terms);
    }
}

sub _multiselect_nonchanged {
    my ($self, $args) = @_;
    my ($chart_id, $joins, $field, $operator) =
        @$args{qw(chart_id joins field operator)};

    my $table = "${field}_$chart_id";
    $args->{full_field} = "$table.value";
    push(@$joins, { table => "bug_$field", as => $table });
}

###############################
# Standard Operator Functions #
###############################

sub _simple_operator {
    my ($self, $args) = @_;
    my ($full_field, $quoted, $operator) =
        @$args{qw(full_field quoted operator)};
    my $sql_operator = SIMPLE_OPERATORS->{$operator};
    $args->{term} = "$full_field $sql_operator $quoted";
}

sub _casesubstring {
    my ($self, $args) = @_;
    my ($full_field, $quoted) = @$args{qw(full_field quoted)};
    my $dbh = Bugzilla->dbh;
    
    $args->{term} = $dbh->sql_position($quoted, $full_field) . " > 0";
}

sub _substring {
    my ($self, $args) = @_;
    my ($full_field, $quoted) = @$args{qw(full_field quoted)};
    my $dbh = Bugzilla->dbh;
    
    # XXX This should probably be changed to just use LIKE
    $args->{term} = $dbh->sql_iposition($quoted, $full_field) . " > 0";
}

sub _notsubstring {
    my ($self, $args) = @_;
    my ($full_field, $quoted) = @$args{qw(full_field quoted)};
    my $dbh = Bugzilla->dbh;
    
    # XXX This should probably be changed to just use NOT LIKE
    $args->{term} = $dbh->sql_iposition($quoted, $full_field) . " = 0";
}

sub _regexp {
    my ($self, $args) = @_;
    my ($full_field, $quoted) = @$args{qw(full_field quoted)};
    my $dbh = Bugzilla->dbh;
    
    $args->{term} = $dbh->sql_regexp($full_field, $quoted);
}

sub _notregexp {
    my ($self, $args) = @_;
    my ($full_field, $quoted) = @$args{qw(full_field quoted)};
    my $dbh = Bugzilla->dbh;
    
    $args->{term} = $dbh->sql_not_regexp($full_field, $quoted);
}

sub _anyexact {
    my ($self, $args) = @_;
    my ($field, $full_field) = @$args{qw(field full_field)};
    my $dbh = Bugzilla->dbh;
    
    my @list = $self->_all_values($args, ',');
    @list = map { $dbh->quote($_) } @list;
    
    if (@list) {
        $args->{term} = $dbh->sql_in($full_field, \@list);
    }
    else {
        $args->{term} = '';
    }
}

sub _anywordsubstr {
    my ($self, $args) = @_;
    my ($full_field, $value) = @$args{qw(full_field value)};
    
    my @terms = $self->_substring_terms($args);
    $args->{term} = join("\n\tOR ", @terms);
}

sub _allwordssubstr {
    my ($self, $args) = @_;
    
    my @terms = $self->_substring_terms($args);
    $args->{term} = join("\n\tAND ", @terms);
}

sub _nowordssubstr {
    my ($self, $args) = @_;
    $self->_anywordsubstr($args);
    my $term = $args->{term};
    $args->{term} = "NOT($term)";
}

sub _anywords {
    my ($self, $args) = @_;
    
    my @terms = $self->_word_terms($args);
    # Because _word_terms uses AND, we need to parenthesize its terms
    # if there are more than one.
    @terms = map("($_)", @terms) if scalar(@terms) > 1;
    $args->{term} = join("\n\tOR ", @terms);
}

sub _allwords {
    my ($self, $args) = @_;
    
    my @terms = $self->_word_terms($args);
    $args->{term} = join("\n\tAND ", @terms);
}

sub _nowords {
    my ($self, $args) = @_;
    $self->_anywords($args);
    my $term = $args->{term};
    $args->{term} = "NOT($term)";
}

sub _changedbefore_changedafter {
    my ($self, $args) = @_;
    my ($chart_id, $joins, $field, $operator, $value) =
        @$args{qw(chart_id joins field operator value)};
    my $dbh = Bugzilla->dbh;
    
    my $sql_operator = ($operator =~ /before/) ? '<=' : '>=';
    my $field_object = $self->_chart_fields->{$field}
        || ThrowCodeError("invalid_field_name", { field => $field });
    my $field_id = $field_object->id;
    # Charts on changed* fields need to be field-specific. Otherwise,
    # OR chart rows make no sense if they contain multiple fields.
    my $table = "act_${field_id}_$chart_id";

    my $sql_date = $dbh->quote(SqlifyDate($value));
    my $join = {
        table => 'bugs_activity',
        as    => $table,
        extra => ["$table.fieldid = $field_id",
                  "$table.bug_when $sql_operator $sql_date"],
    };
    push(@$joins, $join);
    $args->{term} = "$table.bug_when IS NOT NULL";
}

sub _changedfrom_changedto {
    my ($self, $args) = @_;
    my ($chart_id, $joins, $field, $operator, $quoted) =
        @$args{qw(chart_id joins field operator quoted)};
    
    my $column = ($operator =~ /from/) ? 'removed' : 'added';
    my $field_object = $self->_chart_fields->{$field}
        || ThrowCodeError("invalid_field_name", { field => $field });
    my $field_id = $field_object->id;
    my $table = "act_${field_id}_$chart_id";
    my $join = {
        table => 'bugs_activity',
        as    => $table,
        extra => ["$table.fieldid = $field_id",
                  "$table.$column = $quoted"],
    };
    push(@$joins, $join);

    $args->{term} = "$table.bug_when IS NOT NULL";
}

sub _changedby {
    my ($self, $args) = @_;
    my ($chart_id, $joins, $field, $operator, $value) =
        @$args{qw(chart_id joins field operator value)};
    
    my $field_object = $self->_chart_fields->{$field}
        || ThrowCodeError("invalid_field_name", { field => $field });
    my $field_id = $field_object->id;
    my $table = "act_${field_id}_$chart_id";
    my $user_id  = login_to_id($value, THROW_ERROR);
    my $join = {
        table => 'bugs_activity',
        as    => $table,
        extra => ["$table.fieldid = $field_id",
                  "$table.who = $user_id"],
    };
    push(@$joins, $join);
    $args->{term} = "$table.bug_when IS NOT NULL";
}

######################
# Public Subroutines #
######################

# Validate that the query type is one we can deal with
sub IsValidQueryType
{
    my ($queryType) = @_;
    if (grep { $_ eq $queryType } qw(specific advanced)) {
        return 1;
    }
    return 0;
}

# Splits out "asc|desc" from a sort order item.
sub split_order_term {
    my $fragment = shift;
    $fragment =~ /^(.+?)(?:\s+(ASC|DESC))?$/i;
    my ($column_name, $direction) = (lc($1), uc($2 || ''));
    return wantarray ? ($column_name, $direction) : $column_name;
}

# Used to translate old SQL fragments from buglist.cgi's "order" argument
# into our modern field IDs.
sub translate_old_column {
    my ($column) = @_;
    # All old SQL fragments have a period in them somewhere.
    return $column if $column !~ /\./;

    if ($column =~ /\bAS\s+(\w+)$/i) {
        return $1;
    }
    # product, component, classification, assigned_to, qa_contact, reporter
    elsif ($column =~ /map_(\w+?)s?\.(login_)?name/i) {
        return $1;
    }
    
    # If it doesn't match the regexps above, check to see if the old 
    # SQL fragment matches the SQL of an existing column
    foreach my $key (%{ COLUMNS() }) {
        next unless exists COLUMNS->{$key}->{name};
        return $key if COLUMNS->{$key}->{name} eq $column;
    }

    return $column;
}

1;