# This Source Code Form is subject to the terms of the Mozilla Public # License, v. 2.0. If a copy of the MPL was not distributed with this # file, You can obtain one at http://mozilla.org/MPL/2.0/. # # This Source Code Form is "Incompatible With Secondary Licenses", as # defined by the Mozilla Public License, v. 2.0. package Bugzilla::Elastic::Search; use 5.10.1; use Moo; use Bugzilla::Search; use Bugzilla::Search::Quicksearch; use Bugzilla::Util qw(trick_taint); use namespace::clean; use Bugzilla::Elastic::Search::FakeCGI; has 'quicksearch' => (is => 'ro'); has 'limit' => (is => 'ro', predicate => 'has_limit'); has 'offset' => (is => 'ro', predicate => 'has_offset'); has 'fields' => (is => 'ro', isa => \&_arrayref_of_fields, default => sub { [] }); has 'params' => (is => 'lazy'); has 'clause' => (is => 'lazy'); has 'es_query' => (is => 'lazy'); has 'search_description' => (is => 'lazy'); has 'query_time' => (is => 'rwp'); has '_input_order' => (is => 'ro', init_arg => 'order', required => 1); has '_order' => (is => 'lazy', init_arg => undef); has 'invalid_order_columns' => (is => 'lazy'); with 'Bugzilla::Elastic::Role::HasClient'; with 'Bugzilla::Elastic::Role::Search'; my @SUPPORTED_FIELDS = qw( bug_id product component short_desc priority status_whiteboard bug_status resolution keywords alias assigned_to reporter delta_ts longdesc cf_crash_signature classification bug_severity commenter ); my %IS_SUPPORTED_FIELD = map { $_ => 1 } @SUPPORTED_FIELDS; $IS_SUPPORTED_FIELD{relevance} = 1; my @NORMAL_FIELDS = qw( priority bug_severity bug_status resolution product component classification short_desc assigned_to reporter ); my %SORT_MAP = ( bug_id => '_id', relevance => '_score', map { $_ => "$_.eq" } @NORMAL_FIELDS, ); my %EQUALS_MAP = (map { $_ => "$_.eq" } @NORMAL_FIELDS,); sub _arrayref_of_fields { my $f = $_; foreach my $field (@$f) { Bugzilla::Elastic::Search::UnsupportedField->throw(field => $field) unless $IS_SUPPORTED_FIELD{$field}; } } # Future maintainer: Maybe consider removing "changeddate" from the codebase entirely. # At some point, bugzilla tried to rename some fields # one of these is "delta_ts" to changeddate. # But the DB column stayed the same... and elasticsearch uses the db name # However search likes to use the "new" name. # for now we hack a fix in here. my %REMAP_NAME = (changeddate => 'delta_ts',); sub data { my ($self) = @_; my $body = $self->es_query; my $result = eval { $self->client->search( index => Bugzilla::Bug->ES_INDEX, type => Bugzilla::Bug->ES_TYPE, body => $body, ); }; die $@ unless $result; $self->_set_query_time($result->{took} / 1000); my @fields = map { $REMAP_NAME{$_} // $_ } @{$self->fields}; my (@ids, %hits); foreach my $hit (@{$result->{hits}{hits}}) { push @ids, $hit->{_id}; my $source = $hit->{_source}; $source->{relevance} = $hit->{_score}; foreach my $val (values %$source) { next unless defined $val; trick_taint($val); } trick_taint($hit->{_id}); if ($source) { $hits{$hit->{_id}} = [@$source{@fields}]; } else { $hits{$hit->{_id}} = $hit->{_id}; } } my $visible_ids = Bugzilla->user->visible_bugs(\@ids); return [map { $hits{$_} } @$visible_ids]; } sub _valid_order { my ($self) = @_; return grep { $IS_SUPPORTED_FIELD{$_->[0]} } @{$self->_order}; } sub order { my ($self) = @_; return map { $_->[0] } $self->_valid_order; } sub _quicksearch_to_params { my ($quicksearch) = @_; no warnings 'redefine'; my $cgi = Bugzilla::Elastic::Search::FakeCGI->new; local *Bugzilla::cgi = sub {$cgi}; local $Bugzilla::Search::Quicksearch::ELASTIC = 1; quicksearch($quicksearch); return $cgi->params; } sub _build_fields { return \@SUPPORTED_FIELDS } sub _build__order { my ($self) = @_; my @order; foreach my $order (@{$self->_input_order}) { if ($order =~ /^(.+)\s+(asc|desc)$/i) { push @order, [$1, lc $2]; } else { push @order, [$order]; } } return \@order; } sub _build_invalid_order_columns { my ($self) = @_; return [map { $_->[0] } grep { !$IS_SUPPORTED_FIELD{$_->[0]} } @{$self->_order}]; } sub _build_params { my ($self) = @_; return _quicksearch_to_params($self->quicksearch); } sub _build_clause { my ($self) = @_; my $search = Bugzilla::Search->new(params => $self->params); return $search->_params_to_data_structure; } sub _build_search_description { my ($self) = @_; return [_describe($self->clause)]; } sub _describe { my ($thing) = @_; state $class_to_func = { 'Bugzilla::Search::Condition' => \&_describe_condition, 'Bugzilla::Search::Clause' => \&_describe_clause }; my $func = $class_to_func->{ref $thing} or die "nothing for $thing\n"; return $func->($thing); } sub _describe_clause { my ($clause) = @_; return map { _describe($_) } @{$clause->children}; } sub _describe_condition { my ($cond) = @_; return { field => $cond->field, type => $cond->operator, value => _describe_value($cond->value) }; } sub _describe_value { my ($val) = @_; return ref($val) ? join(", ", @$val) : $val; } sub _build_es_query { my ($self) = @_; my @extra; if ($self->_valid_order) { my @sort = map { my $f = $SORT_MAP{$_->[0]} // $_->[0]; @$_ > 1 ? {$f => lc $_[1]} : $f; } $self->_valid_order; push @extra, sort => \@sort; } if ($self->has_offset) { push @extra, from => $self->offset; } my $max_limit = Bugzilla->params->{max_search_results}; my $limit = Bugzilla->params->{default_search_limit}; if ($self->has_limit) { if ($self->limit) { my $l = $self->limit; $limit = $l < $max_limit ? $l : $max_limit; } else { $limit = $max_limit; } } push @extra, size => $limit; return { _source => @{$self->fields} ? \1 : \0, query => _query($self->clause), @extra, }; } sub _query { my ($thing) = @_; state $class_to_func = { 'Bugzilla::Search::Condition' => \&_query_condition, 'Bugzilla::Search::Clause' => \&_query_clause, }; my $func = $class_to_func->{ref $thing} or die "nothing for $thing\n"; return $func->($thing); } sub _query_condition { my ($cond) = @_; state $operator_to_es = { equals => \&_operator_equals, substring => \&_operator_substring, anyexact => \&_operator_anyexact, anywords => \&_operator_anywords, allwords => \&_operator_allwords, }; my $field = $cond->field; my $operator = $cond->operator; my $value = $cond->value; if ($field eq 'resolution') { $value = [map { $_ eq '---' ? '' : $_ } ref $value ? @$value : $value]; } unless ($IS_SUPPORTED_FIELD{$field}) { Bugzilla::Elastic::Search::UnsupportedField->throw(field => $field); } my $op = $operator_to_es->{$operator} or Bugzilla::Elastic::Search::UnsupportedOperator->throw(operator => $operator); my $result; if (ref $op) { $result = $op->($field, $value); } else { $result = {$op => {$field => $value}}; } return $result; } # is equal to any of the strings sub _operator_anyexact { my ($field, $value) = @_; my @values = ref $value ? @$value : split(/\s*,\s*/, $value); if (@values == 1) { return _operator_equals($field, $values[0]); } else { return { terms => { $EQUALS_MAP{$field} // $field => [map {lc} @values], minimum_should_match => 1, }, }; } } # contains any of the words sub _operator_anywords { my ($field, $value) = @_; return {match => {$field => {query => $value, operator => "or"}},}; } # contains all of the words sub _operator_allwords { my ($field, $value) = @_; return {match => {$field => {query => $value, operator => "and"}},}; } sub _operator_equals { my ($field, $value) = @_; return {match => {$EQUALS_MAP{$field} // $field => $value,},}; } sub _operator_substring { my ($field, $value) = @_; my $is_insider = Bugzilla->user->is_insider; if ($field eq 'longdesc') { return { has_child => { type => 'comment', query => { bool => { must => [ {match => {body => {query => $value, operator => "and"}}}, $is_insider ? () : {term => {is_private => \0}}, ], }, }, }, }; } elsif ($field eq 'reporter' or $field eq 'assigned_to') { return {prefix => {$EQUALS_MAP{$field} // $field => lc $value,}}; } elsif ($field eq 'status_whiteboard' && $value =~ /[\[\]]/) { return {match => {$EQUALS_MAP{$field} // $field => $value,}}; } else { return {wildcard => {$EQUALS_MAP{$field} // $field => lc "*$value*",}}; } } sub _query_clause { my ($clause) = @_; state $joiner_to_func = {AND => \&_join_and, OR => \&_join_or,}; my @children = grep { !$_->isa('Bugzilla::Search::Clause') || @{$_->children} } @{$clause->children}; if (@children == 1) { return _query($children[0]); } return $joiner_to_func->{$clause->joiner}->([map { _query($_) } @children]); } sub _join_and { my ($children) = @_; return {bool => {must => $children}},; } sub _join_or { my ($children) = @_; return {bool => {should => $children}}; } # Exceptions BEGIN { package Bugzilla::Elastic::Search::Redirect; use Moo; with 'Throwable'; has 'redirect_args' => (is => 'ro', required => 1); package Bugzilla::Elastic::Search::UnsupportedField; use Moo; use overload q{""} => sub { "Unsupported field: ", $_[0]->field }, fallback => 1; with 'Throwable'; has 'field' => (is => 'ro', required => 1); package Bugzilla::Elastic::Search::UnsupportedOperator; use Moo; with 'Throwable'; has 'operator' => (is => 'ro', required => 1); } 1;