From 06b67a7dab8690cc1621067a9aa5a5e09d4c3586 Mon Sep 17 00:00:00 2001 From: Zsolt Parragi Date: Fri, 6 Feb 2026 19:43:32 +0100 Subject: [PATCH] Advanced search This commit adds a new advanced search functionality with a proper parser, so that we can base a new dynamic saved search functionality on this instead of the currently existing hard coded quick filters. --- Gemfile | 1 + Gemfile.lock | 2 + app/assets/stylesheets/components/sidebar.css | 19 + app/assets/stylesheets/components/topics.css | 72 ++ app/controllers/help_controller.rb | 3 +- app/controllers/topics_controller.rb | 93 +- app/services/search/date_parser.rb | 69 ++ app/services/search/query_builder.rb | 857 ++++++++++++++++++ app/services/search/query_parser.rb | 312 +++++++ app/services/search/query_validator.rb | 349 +++++++ app/services/search/value_resolver.rb | 224 +++++ app/views/help/pages/search.md | 509 +++++++++++ app/views/topics/_sidebar.html.slim | 4 + app/views/topics/search.html.slim | 21 + ...20260206173214_add_fts_tsvector_columns.rb | 31 + db/schema.rb | 6 +- spec/factories/note_mentions.rb | 6 + spec/factories/note_tags.rb | 6 + spec/rails_helper.rb | 3 +- spec/services/search/date_parser_spec.rb | 97 ++ spec/services/search/integration_spec.rb | 240 +++++ spec/services/search/query_builder_spec.rb | 847 +++++++++++++++++ spec/services/search/query_parser_spec.rb | 336 +++++++ spec/services/search/query_validator_spec.rb | 252 +++++ spec/services/search/value_resolver_spec.rb | 195 ++++ 25 files changed, 4521 insertions(+), 33 deletions(-) create mode 100644 app/services/search/date_parser.rb create mode 100644 app/services/search/query_builder.rb create mode 100644 app/services/search/query_parser.rb create mode 100644 app/services/search/query_validator.rb create mode 100644 app/services/search/value_resolver.rb create mode 100644 app/views/help/pages/search.md create mode 100644 db/migrate/20260206173214_add_fts_tsvector_columns.rb create mode 100644 spec/factories/note_mentions.rb create mode 100644 spec/factories/note_tags.rb create mode 100644 spec/services/search/date_parser_spec.rb create mode 100644 spec/services/search/integration_spec.rb create mode 100644 spec/services/search/query_builder_spec.rb create mode 100644 spec/services/search/query_parser_spec.rb create mode 100644 spec/services/search/query_validator_spec.rb create mode 100644 spec/services/search/value_resolver_spec.rb diff --git a/Gemfile b/Gemfile index 3d2deb5..a886fb0 100644 --- a/Gemfile +++ b/Gemfile @@ -24,6 +24,7 @@ gem "kaminari" gem "nokogiri" gem "csv" gem "redcarpet" +gem "parslet" # Use Active Model has_secure_password [https://guides.rubyonrails.org/active_model_basics.html#securepassword] gem "bcrypt", "~> 3.1" diff --git a/Gemfile.lock b/Gemfile.lock index eb06912..9228e5a 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -268,6 +268,7 @@ GEM parser (3.3.10.0) ast (~> 2.4.1) racc + parslet (2.0.0) pg (1.6.2) pg (1.6.2-aarch64-linux) pg (1.6.2-aarch64-linux-musl) @@ -500,6 +501,7 @@ DEPENDENCIES omniauth omniauth-google-oauth2 omniauth-rails_csrf_protection + parslet pg (~> 1.1) pghero propshaft diff --git a/app/assets/stylesheets/components/sidebar.css b/app/assets/stylesheets/components/sidebar.css index b5f4903..d74bc86 100644 --- a/app/assets/stylesheets/components/sidebar.css +++ b/app/assets/stylesheets/components/sidebar.css @@ -282,3 +282,22 @@ .sidebar .commitfest-committers li + li { margin-top: var(--spacing-1); } + +.sidebar .search-help-link { + margin-top: var(--spacing-3); + text-align: center; +} + +.sidebar .search-help-link a { + color: var(--color-text-secondary); + font-size: var(--font-size-sm); + text-decoration: none; + display: inline-flex; + align-items: center; + gap: var(--spacing-1); + transition: color var(--transition-fast); +} + +.sidebar .search-help-link a:hover { + color: var(--color-text-link); +} diff --git a/app/assets/stylesheets/components/topics.css b/app/assets/stylesheets/components/topics.css index 0d61a4b..90407c3 100644 --- a/app/assets/stylesheets/components/topics.css +++ b/app/assets/stylesheets/components/topics.css @@ -730,3 +730,75 @@ a.topic-icon { background: var(--color-primary-50); border-color: var(--color-primary-200); } + +/* Search errors and warnings */ +.search-error { + background: var(--color-danger-soft); + border: var(--border-width) solid var(--color-danger); + border-radius: var(--border-radius-lg); + padding: var(--spacing-5); + margin-bottom: var(--spacing-5); + display: flex; + gap: var(--spacing-4); + align-items: flex-start; +} + +.search-error-icon { + color: var(--color-danger); + font-size: var(--font-size-xl); + flex-shrink: 0; +} + +.search-error-content h3 { + margin: 0 0 var(--spacing-2) 0; + color: var(--color-danger); + font-size: var(--font-size-lg); + font-weight: var(--font-weight-semibold); +} + +.search-error-content p { + margin: 0 0 var(--spacing-2) 0; + color: var(--color-text-primary); +} + +.search-error-help { + font-size: var(--font-size-sm); +} + +.search-error-help a { + color: var(--color-danger); + text-decoration: underline; + font-weight: var(--font-weight-medium); +} + +.search-warnings { + background: var(--color-warning-bg); + border: var(--border-width) solid var(--color-warning); + border-radius: var(--border-radius-lg); + padding: var(--spacing-4); + margin-bottom: var(--spacing-4); +} + +.search-warnings-header { + display: flex; + align-items: center; + gap: var(--spacing-2); + color: var(--color-warning-text); + font-weight: var(--font-weight-medium); + margin-bottom: var(--spacing-2); +} + +.search-warnings-header i { + font-size: var(--font-size-lg); +} + +.search-warnings-list { + margin: 0; + padding-left: var(--spacing-6); + color: var(--color-warning-text); + font-size: var(--font-size-sm); +} + +.search-warnings-list li + li { + margin-top: var(--spacing-1); +} diff --git a/app/controllers/help_controller.rb b/app/controllers/help_controller.rb index 577ea13..21d5ca6 100644 --- a/app/controllers/help_controller.rb +++ b/app/controllers/help_controller.rb @@ -4,8 +4,9 @@ class HelpController < ApplicationController layout "help" PAGES = { + "search" => "Advanced Search Guide", "hackorum-patch" => "Applying Patches with hackorum-patch", - "account-linking" => "Account Linking & Multiple Emails" + "account-linking" => "Account Linking & Multiple Emails", }.freeze def index diff --git a/app/controllers/topics_controller.rb b/app/controllers/topics_controller.rb index 01c0e2f..f9a0967 100644 --- a/app/controllers/topics_controller.rb +++ b/app/controllers/topics_controller.rb @@ -202,11 +202,34 @@ def search return end - load_cached_search_results + @search_warnings = [] + + begin + # Parse the search query + parser = Search::QueryParser.new + ast = parser.parse(@search_query) + + # Validate and collect warnings + validator = Search::QueryValidator.new(ast) + validated = validator.validate + @search_warnings += validated.warnings + + # Build the ActiveRecord query + builder = Search::QueryBuilder.new(ast: validated.ast, user: current_user) + result = builder.build + @search_warnings += result.warnings + + # Load results + load_search_results(result.relation) + rescue Parslet::ParseFailed => e + @search_error = format_parse_error(e) + @topics = [] + end preload_topic_participants preload_commitfest_summaries preload_participation_flags if user_signed_in? + load_visible_tags if user_signed_in? respond_to do |format| format.html @@ -857,41 +880,19 @@ def load_star_state @is_starred = TopicStar.exists?(user: current_user, topic: @topic) end - def load_cached_search_results + SEARCH_PAGE_SIZE = 1000 + + def load_search_results(base_relation) @viewing_since = viewing_since_param longpage = params[:longpage].to_i - cache = SearchResultCache.new(query: @search_query, scope: "title_body", viewing_since: @viewing_since, longpage: longpage) - - result = cache.fetch do |limit, offset| - build_search_query(@search_query) - .joins(:messages) - .where(messages: { created_at: ..@viewing_since }) - .group('topics.id') - .select('topics.id, topics.creator_id, MAX(messages.created_at) as last_activity') - .order('MAX(messages.created_at) DESC, topics.id DESC') - .limit(limit) - .offset(offset) - .load - end - entries = result[:entries] || [] + entries = execute_search_query(base_relation, longpage) sliced = slice_cached_entries(entries, params[:cursor]) - if sliced[:entries].empty? && entries.size >= SearchResultCache::LONGPAGE_SIZE + # Handle pagination to next longpage if needed + if sliced[:entries].empty? && entries.size >= SEARCH_PAGE_SIZE longpage += 1 - cache = SearchResultCache.new(query: @search_query, scope: "title_body", viewing_since: @viewing_since, longpage: longpage) - next_result = cache.fetch do |limit, offset| - build_search_query(@search_query) - .joins(:messages) - .where(messages: { created_at: ..@viewing_since }) - .group('topics.id') - .select('topics.id, topics.creator_id, MAX(messages.created_at) as last_activity') - .order('MAX(messages.created_at) DESC, topics.id DESC') - .limit(limit) - .offset(offset) - .load - end - entries = next_result[:entries] || [] + entries = execute_search_query(base_relation, longpage) sliced = slice_cached_entries(entries, params[:cursor]) end @@ -901,6 +902,38 @@ def load_cached_search_results @new_topics_count = 0 end + def execute_search_query(base_relation, longpage) + results = base_relation + .joins(:messages) + .where(messages: { created_at: ..@viewing_since }) + .group("topics.id") + .select("topics.id, topics.creator_id, MAX(messages.created_at) as last_activity") + .order("MAX(messages.created_at) DESC, topics.id DESC") + .limit(SEARCH_PAGE_SIZE) + .offset(SEARCH_PAGE_SIZE * longpage) + .load + + results.map do |row| + { + id: row.id, + last_activity: row.try(:last_activity)&.to_time || row.try(:created_at)&.to_time + } + end + end + + def format_parse_error(error) + # Extract user-friendly error message from Parslet error + cause = error.parse_failure_cause + if cause + line = cause.pos.line_and_column.first rescue 1 + "Syntax error at position #{line}: #{cause.message}" + else + "Invalid search syntax" + end + rescue StandardError + "Invalid search syntax" + end + def preload_commitfest_summaries topic_ids = @topics.map(&:id) @commitfest_summaries = Topic.commitfest_summaries(topic_ids) diff --git a/app/services/search/date_parser.rb b/app/services/search/date_parser.rb new file mode 100644 index 0000000..8f1ca24 --- /dev/null +++ b/app/services/search/date_parser.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +module Search + # Parses date strings for search queries. + # Supports absolute dates (2024-01-01, 2024-01, 2024) and relative dates (today, yesterday, 7d, 2w, 3m, 1y) + class DateParser + RELATIVE_PATTERNS = { + /\Atoday\z/i => -> { Time.current.beginning_of_day }, + /\Ayesterday\z/i => -> { 1.day.ago.beginning_of_day }, + /\A(\d+)d\z/i => ->(n) { n.to_i.days.ago }, + /\A(\d+)w\z/i => ->(n) { n.to_i.weeks.ago }, + /\A(\d+)m\z/i => ->(n) { (n.to_i * 30).days.ago }, + /\A(\d+)y\z/i => ->(n) { (n.to_i * 365).days.ago } + }.freeze + + def initialize(value) + @value = value.to_s.strip + end + + def parse + return nil if @value.blank? + + parse_relative || parse_absolute + end + + def valid? + parse.present? + end + + private + + def parse_relative + RELATIVE_PATTERNS.each do |pattern, handler| + match = @value.match(pattern) + next unless match + + if match.captures.empty? + return handler.call + else + return handler.call(match[1]) + end + end + + nil + end + + def parse_absolute + case @value + when /\A(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})/ + # Full ISO timestamp: 2024-01-15T10:30:00 + Time.zone.parse(@value) + when /\A(\d{4})-(\d{2})-(\d{2})\z/ + # Full date: 2024-01-01 + Time.zone.parse("#{@value} 00:00:00") + when /\A(\d{4})-(\d{2})\z/ + # Month only: 2024-01 + Time.zone.parse("#{@value}-01 00:00:00") + when /\A(\d{4})\z/ + # Year only: 2024 + Time.zone.parse("#{@value}-01-01 00:00:00") + else + # Try a generic parse as last resort + Time.zone.parse(@value) + end + rescue ArgumentError, TypeError + nil + end + end +end diff --git a/app/services/search/query_builder.rb b/app/services/search/query_builder.rb new file mode 100644 index 0000000..593942f --- /dev/null +++ b/app/services/search/query_builder.rb @@ -0,0 +1,857 @@ +# frozen_string_literal: true + +module Search + # Converts AST to ActiveRecord relation. + # Handles all selector types, boolean logic, and negation. + class QueryBuilder + Result = Struct.new(:relation, :warnings, keyword_init: true) + + ALLOWED_COUNT_COLUMNS = %i[message_count participant_count contributor_participant_count].freeze + ALLOWED_DATE_COLUMNS = %i[created_at last_message_at].freeze + + def initialize(ast:, user:) + @ast = ast + @user = user + @warnings = [] + @value_resolver = ValueResolver.new(user: user) + end + + def build + return Result.new(relation: Topic.none, warnings: []) if @ast.nil? + + relation = apply_node(@ast, Topic.all) + Result.new(relation: relation, warnings: @warnings) + end + + private + + def apply_node(node, relation) + return relation if node.nil? + + case node[:type] + when :and + result = apply_and(node[:children], relation) + node[:negated] ? negate_relation(result, relation) : result + when :or + result = apply_or(node[:children], relation) + node[:negated] ? negate_relation(result, relation) : result + when :selector + apply_selector(node, relation) + when :text + apply_text_search(node, relation) + else + relation + end + end + + def negate_relation(inner_relation, outer_relation) + # Exclude topics that match the inner relation + outer_relation.where.not(id: inner_relation.select(:id)) + end + + def apply_and(children, relation) + children.reduce(relation) { |rel, child| apply_node(child, rel) } + end + + def apply_or(children, relation) + return relation if children.empty? + + # Build separate queries for each child and combine with OR + subqueries = children.map { |child| apply_node(child, Topic.all) } + + # Combine using UNION via topic IDs + combined_ids = subqueries.map { |sq| sq.select(:id) } + + # Use SQL UNION for the subqueries + if combined_ids.size == 1 + relation.where(id: combined_ids.first) + else + union_sql = combined_ids.map { |sq| "(#{sq.to_sql})" }.join(" UNION ") + relation.where("topics.id IN (#{union_sql})") + end + end + + def apply_selector(node, relation) + key = node[:key] + value = node[:value] + negated = node[:negated] + quoted = node[:quoted] + conditions = node[:conditions] + + result = case key + when :from + apply_from_selector(value, relation, negated: negated, quoted: quoted, conditions: conditions) + when :starter + apply_starter_selector(value, relation, negated: negated, quoted: quoted) + when :last_from + apply_last_from_selector(value, relation, negated: negated, quoted: quoted) + when :title + apply_title_selector(value, relation, negated: negated, quoted: quoted) + when :body + apply_body_selector(value, relation, negated: negated, quoted: quoted) + when :unread + apply_unread_selector(value, relation, negated: negated) + when :read + apply_read_selector(value, relation, negated: negated) + when :reading + apply_reading_selector(value, relation, negated: negated) + when :new + apply_new_selector(value, relation, negated: negated) + when :starred + apply_starred_selector(value, relation, negated: negated) + when :notes + apply_notes_selector(value, relation, negated: negated) + when :tag + apply_tag_selector(value, relation, negated: negated, conditions: conditions) + when :has + apply_has_selector(value, relation, negated: negated, conditions: conditions) + when :messages + apply_count_selector(:message_count, value, relation, negated: negated) + when :participants + apply_count_selector(:participant_count, value, relation, negated: negated) + when :contributors + apply_count_selector(:contributor_participant_count, value, relation, negated: negated) + when :first_after + apply_date_selector(:created_at, :>=, value, relation, negated: negated) + when :first_before + apply_date_selector(:created_at, :<, value, relation, negated: negated) + when :messages_after + apply_messages_date_selector(:>=, value, relation, negated: negated) + when :messages_before + apply_messages_date_selector(:<, value, relation, negated: negated) + when :last_after + apply_last_date_selector(:>=, value, relation, negated: negated) + when :last_before + apply_last_date_selector(:<, value, relation, negated: negated) + else + @warnings << "Unknown selector: #{key}" + relation + end + + result || relation + end + + def apply_text_search(node, relation) + value = node[:value] + negated = node[:negated] + quoted = node[:quoted] + + return relation if value.blank? + + # Text search: check both title and body + # Using FTS if available, falling back to ILIKE + # Quoted text uses phrase matching (words must be adjacent) + condition = build_text_search_condition(value, phrase: quoted) + + if negated + relation.where.not(id: Topic.where(condition)) + else + relation.where(condition) + end + end + + def build_text_search_condition(value, phrase: false) + # Try to use FTS first, with ILIKE fallback + # Check if title_tsv column exists + if Topic.column_names.include?('title_tsv') + # Use FTS - phraseto_tsquery for phrase matching, plainto_tsquery otherwise + sanitized = sanitize_fts_query(value) + tsquery_func = phrase ? "phraseto_tsquery" : "plainto_tsquery" + title_fts = "topics.title_tsv @@ #{tsquery_func}('english', #{ActiveRecord::Base.connection.quote(sanitized)})" + + # Check if messages have body_tsv + if Message.column_names.include?('body_tsv') + body_topic_ids = Message.where( + "body_tsv @@ #{tsquery_func}('english', ?)", sanitized + ).select(:topic_id) + Arel.sql("(#{title_fts} OR topics.id IN (#{body_topic_ids.to_sql}))") + else + # Fall back to ILIKE for body + pattern = "%#{sanitize_like(value)}%" + body_topic_ids = Message.where("body ILIKE ?", pattern).select(:topic_id) + Arel.sql("(#{title_fts} OR topics.id IN (#{body_topic_ids.to_sql}))") + end + else + # Fall back to ILIKE for both + pattern = "%#{sanitize_like(value)}%" + body_topic_ids = Message.where("body ILIKE ?", pattern).select(:topic_id) + Arel.sql("(topics.title ILIKE #{ActiveRecord::Base.connection.quote(pattern)} OR topics.id IN (#{body_topic_ids.to_sql}))") + end + end + + # === Author Selectors === + + def apply_from_selector(value, relation, negated:, quoted:, conditions: nil) + result = @value_resolver.resolve_author(value, quoted: quoted) + @warnings.concat(result.warnings) + + person_ids = result.person_ids + return relation if person_ids.empty? + + if conditions.blank? + # Original behavior - any message from these persons + message_topic_ids = Message.where(sender_person_id: person_ids).select(:topic_id) + return negated ? relation.where.not(id: message_topic_ids) : relation.where(id: message_topic_ids) + end + + # With conditions: use topic_participants with conditions + topic_ids = build_from_condition_query(person_ids, conditions) + negated ? relation.where.not(id: topic_ids) : relation.where(id: topic_ids) + end + + def build_from_condition_query(person_ids, conditions) + # Separate conditions into participant-level and message-level + participant_conditions = conditions.reject { |c| c[:key] == :body } + message_conditions = conditions.select { |c| c[:key] == :body } + + # Start with topic_participants for the given persons + base = TopicParticipant.where(person_id: person_ids) + + # Apply participant-level row conditions (dates, etc.) + row_conditions = participant_conditions.reject { |c| c[:key] == :messages } + agg_conditions = participant_conditions.select { |c| c[:key] == :messages } + + row_conditions.each { |cond| base = apply_participant_row_condition(base, cond) } + + # Handle message conditions (body:) - need subquery on messages + if message_conditions.any? + body_cond = message_conditions.first + body_topic_ids = build_body_condition_subquery(person_ids, body_cond[:value], body_cond[:quoted]) + base = base.where(topic_id: body_topic_ids) + end + + if person_ids.size == 1 && agg_conditions.any? + # Single person: can use row-level message_count directly + agg_conditions.each { |cond| base = apply_participant_row_condition(base, cond) } + base.select(:topic_id) + elsif agg_conditions.any? + # Team: need GROUP BY with HAVING for combined message count + grouped = base.group(:topic_id) + agg_conditions.each do |cond| + case cond[:key] + when :messages + op, num = parse_count_value(cond[:value]) + next unless num + grouped = grouped.having("SUM(topic_participants.message_count) #{op} ?", num) + end + end + grouped.select(:topic_id) + else + base.select(:topic_id) + end + end + + def apply_participant_row_condition(participants, condition) + case condition[:key] + when :messages + op, num = parse_count_value(condition[:value]) + return participants unless num + participants.where("topic_participants.message_count #{op} ?", num) + when :last_before + date = DateParser.new(condition[:value]).parse + return participants unless date + participants.where("topic_participants.last_message_at < ?", date) + when :last_after + date = DateParser.new(condition[:value]).parse + return participants unless date + participants.where("topic_participants.last_message_at >= ?", date) + when :first_before + date = DateParser.new(condition[:value]).parse + return participants unless date + participants.where("topic_participants.first_message_at < ?", date) + when :first_after + date = DateParser.new(condition[:value]).parse + return participants unless date + participants.where("topic_participants.first_message_at >= ?", date) + else + participants + end + end + + def build_body_condition_subquery(person_ids, body_value, quoted) + # Find topics where any of the persons posted a message matching body + if Message.column_names.include?('body_tsv') + sanitized = sanitize_fts_query(body_value) + tsquery_func = quoted ? "phraseto_tsquery" : "plainto_tsquery" + Message.where(sender_person_id: person_ids) + .where("body_tsv @@ #{tsquery_func}('english', ?)", sanitized) + .select(:topic_id) + else + pattern = "%#{sanitize_like(body_value)}%" + Message.where(sender_person_id: person_ids) + .where("body ILIKE ?", pattern) + .select(:topic_id) + end + end + + def apply_starter_selector(value, relation, negated:, quoted:) + result = @value_resolver.resolve_author(value, quoted: quoted) + @warnings.concat(result.warnings) + + person_ids = result.person_ids + return relation if person_ids.empty? + + if negated + relation.where.not(creator_person_id: person_ids) + else + relation.where(creator_person_id: person_ids) + end + end + + def apply_last_from_selector(value, relation, negated:, quoted:) + result = @value_resolver.resolve_author(value, quoted: quoted) + @warnings.concat(result.warnings) + + person_ids = result.person_ids + return relation if person_ids.empty? + + if negated + relation.where.not(last_sender_person_id: person_ids) + else + relation.where(last_sender_person_id: person_ids) + end + end + + # === Content Selectors === + + def apply_title_selector(value, relation, negated:, quoted: false) + return relation if value.blank? + + condition = if Topic.column_names.include?('title_tsv') + sanitized = sanitize_fts_query(value) + # Use phraseto_tsquery for quoted values (phrase matching), plainto_tsquery otherwise + tsquery_func = quoted ? "phraseto_tsquery" : "plainto_tsquery" + ["topics.title_tsv @@ #{tsquery_func}('english', ?)", sanitized] + else + pattern = "%#{sanitize_like(value)}%" + ["topics.title ILIKE ?", pattern] + end + + if negated + relation.where.not(*condition) + else + relation.where(*condition) + end + end + + def apply_body_selector(value, relation, negated:, quoted: false) + return relation if value.blank? + + message_topic_ids = if Message.column_names.include?('body_tsv') + sanitized = sanitize_fts_query(value) + # Use phraseto_tsquery for quoted values (phrase matching), plainto_tsquery otherwise + tsquery_func = quoted ? "phraseto_tsquery" : "plainto_tsquery" + Message.where("body_tsv @@ #{tsquery_func}('english', ?)", sanitized).select(:topic_id) + else + pattern = "%#{sanitize_like(value)}%" + Message.where("body ILIKE ?", pattern).select(:topic_id) + end + + if negated + relation.where.not(id: message_topic_ids) + else + relation.where(id: message_topic_ids) + end + end + + # === State Selectors === + + def apply_unread_selector(value, relation, negated:) + result = @value_resolver.resolve_state_subject(value) + @warnings.concat(result.warnings) + + user_ids = result.user_ids + return relation if user_ids.empty? + + # Topics where any of these users have unread messages + # A topic is unread if max_message_id > max_read_range_end + if user_ids.size == 1 + apply_unread_for_user(user_ids.first, relation, negated: negated) + else + apply_unread_for_users(user_ids, relation, negated: negated) + end + end + + def apply_unread_for_user(user_id, relation, negated:) + # Unread: last message id > max read range end + sql = <<~SQL.squish + topics.id IN ( + SELECT t.id FROM topics t + LEFT JOIN message_read_ranges mrr ON mrr.topic_id = t.id AND mrr.user_id = #{user_id.to_i} + GROUP BY t.id + HAVING t.last_message_id > COALESCE(MAX(mrr.range_end_message_id), 0) + ) + SQL + + if negated + relation.where.not(Arel.sql(sql)) + else + relation.where(Arel.sql(sql)) + end + end + + def apply_unread_for_users(user_ids, relation, negated:) + # For team: topic is unread if NO team member has fully read + sanitized_ids = user_ids.map(&:to_i).join(',') + fully_read_sql = <<~SQL.squish + SELECT DISTINCT mrr.topic_id FROM message_read_ranges mrr + JOIN topics t ON t.id = mrr.topic_id + WHERE mrr.user_id IN (#{sanitized_ids}) + GROUP BY mrr.topic_id, t.last_message_id + HAVING MAX(mrr.range_end_message_id) >= t.last_message_id + SQL + + if negated + # Negated unread = fully read by at least one team member + relation.where(Arel.sql("topics.id IN (#{fully_read_sql})")) + else + # Unread = not fully read by any team member + relation.where(Arel.sql("topics.id NOT IN (#{fully_read_sql})")) + end + end + + def apply_read_selector(value, relation, negated:) + result = @value_resolver.resolve_state_subject(value) + @warnings.concat(result.warnings) + + user_ids = result.user_ids + return relation if user_ids.empty? + + # Topics that are fully read + if user_ids.size == 1 + apply_read_for_user(user_ids.first, relation, negated: negated) + else + apply_read_for_users(user_ids, relation, negated: negated) + end + end + + def apply_read_for_user(user_id, relation, negated:) + sql = <<~SQL.squish + topics.id IN ( + SELECT t.id FROM topics t + JOIN message_read_ranges mrr ON mrr.topic_id = t.id AND mrr.user_id = #{user_id.to_i} + GROUP BY t.id + HAVING MAX(mrr.range_end_message_id) >= t.last_message_id + ) + SQL + + if negated + relation.where.not(Arel.sql(sql)) + else + relation.where(Arel.sql(sql)) + end + end + + def apply_read_for_users(user_ids, relation, negated:) + sanitized_ids = user_ids.map(&:to_i).join(',') + sql = <<~SQL.squish + topics.id IN ( + SELECT mrr.topic_id FROM message_read_ranges mrr + JOIN topics t ON t.id = mrr.topic_id + WHERE mrr.user_id IN (#{sanitized_ids}) + GROUP BY mrr.topic_id, t.last_message_id + HAVING MAX(mrr.range_end_message_id) >= t.last_message_id + ) + SQL + + if negated + relation.where.not(Arel.sql(sql)) + else + relation.where(Arel.sql(sql)) + end + end + + def apply_reading_selector(value, relation, negated:) + result = @value_resolver.resolve_state_subject(value) + @warnings.concat(result.warnings) + + user_ids = result.user_ids + return relation if user_ids.empty? + + # Topics partially read (some read, but not all) + sanitized_ids = user_ids.map(&:to_i).join(',') + sql = <<~SQL.squish + topics.id IN ( + SELECT mrr.topic_id FROM message_read_ranges mrr + JOIN topics t ON t.id = mrr.topic_id + WHERE mrr.user_id IN (#{sanitized_ids}) + GROUP BY mrr.topic_id, t.last_message_id + HAVING MAX(mrr.range_end_message_id) > 0 + AND MAX(mrr.range_end_message_id) < t.last_message_id + ) + SQL + + if negated + relation.where.not(Arel.sql(sql)) + else + relation.where(Arel.sql(sql)) + end + end + + def apply_new_selector(value, relation, negated:) + result = @value_resolver.resolve_state_subject(value) + @warnings.concat(result.warnings) + + user_ids = result.user_ids + return relation if user_ids.empty? + + # New: never seen (no awareness, no read ranges, after user's aware_before) + if user_ids.size == 1 + apply_new_for_user(user_ids.first, relation, negated: negated) + else + # For team, new = no team member has any awareness + apply_new_for_users(user_ids, relation, negated: negated) + end + end + + def apply_new_for_user(user_id, relation, negated:) + user = User.find_by(id: user_id) + aware_before = user&.aware_before + + sql = if aware_before + aware_before_sql = ActiveRecord::Base.connection.quote(aware_before) + <<~SQL.squish + topics.id IN ( + SELECT t.id FROM topics t + LEFT JOIN thread_awareness ta ON ta.topic_id = t.id AND ta.user_id = #{user_id.to_i} + LEFT JOIN message_read_ranges mrr ON mrr.topic_id = t.id AND mrr.user_id = #{user_id.to_i} + WHERE ta.aware_until_message_id IS NULL + GROUP BY t.id + HAVING COALESCE(MAX(mrr.range_end_message_id), 0) = 0 + AND t.last_message_at > #{aware_before_sql} + ) + SQL + else + <<~SQL.squish + topics.id IN ( + SELECT t.id FROM topics t + LEFT JOIN thread_awareness ta ON ta.topic_id = t.id AND ta.user_id = #{user_id.to_i} + LEFT JOIN message_read_ranges mrr ON mrr.topic_id = t.id AND mrr.user_id = #{user_id.to_i} + WHERE ta.aware_until_message_id IS NULL + GROUP BY t.id + HAVING COALESCE(MAX(mrr.range_end_message_id), 0) = 0 + ) + SQL + end + + if negated + relation.where.not(Arel.sql(sql)) + else + relation.where(Arel.sql(sql)) + end + end + + def apply_new_for_users(user_ids, relation, negated:) + sanitized_ids = user_ids.map(&:to_i).join(',') + # For team: topic is new if NO team member has any awareness or reads + seen_sql = <<~SQL.squish + SELECT DISTINCT topic_id FROM thread_awareness WHERE user_id IN (#{sanitized_ids}) + UNION + SELECT DISTINCT topic_id FROM message_read_ranges WHERE user_id IN (#{sanitized_ids}) + SQL + + if negated + # Negated new = someone has seen it + relation.where(Arel.sql("topics.id IN (#{seen_sql})")) + else + # New = nobody has seen it + relation.where(Arel.sql("topics.id NOT IN (#{seen_sql})")) + end + end + + def apply_starred_selector(value, relation, negated:) + result = @value_resolver.resolve_state_subject(value) + @warnings.concat(result.warnings) + + user_ids = result.user_ids + return relation if user_ids.empty? + + starred_topic_ids = TopicStar.where(user_id: user_ids).select(:topic_id) + + if negated + relation.where.not(id: starred_topic_ids) + else + relation.where(id: starred_topic_ids) + end + end + + def apply_notes_selector(value, relation, negated:) + result = @value_resolver.resolve_state_subject(value) + @warnings.concat(result.warnings) + + user_ids = result.user_ids + return relation if user_ids.empty? + + note_topic_ids = Note.where(author_id: user_ids, deleted_at: nil).select(:topic_id) + + if negated + relation.where.not(id: note_topic_ids) + else + relation.where(id: note_topic_ids) + end + end + + def apply_tag_selector(value, relation, negated:, conditions: nil) + # Use bracket syntax handling for all tag queries + apply_tag_with_conditions(value, relation, negated: negated, conditions: conditions || []) + end + + def apply_tag_with_conditions(tag_name, relation, negated:, conditions:) + unless @user + @warnings << "Must be signed in to search by tags" + return relation.none + end + + # Start with notes visible to the current user + notes = Note.active.visible_to(@user).joins(:note_tags) + + # Filter by tag name if provided + if tag_name.present? + notes = notes.where("LOWER(note_tags.tag) = LOWER(?)", tag_name) + end + + # Apply conditions + from_cond = conditions.find { |c| c[:key] == :from } + added_before_cond = conditions.find { |c| c[:key] == :added_before } + added_after_cond = conditions.find { |c| c[:key] == :added_after } + + # Apply from: condition + if from_cond + from_value = from_cond[:value] + if from_value == 'me' + notes = notes.where(author_id: @user.id) + else + # Try to resolve as team or username + team = Team.joins(:team_members) + .where(team_members: { user_id: @user.id }) + .find_by("LOWER(teams.name) = LOWER(?)", from_value) + if team + user_ids = TeamMember.where(team_id: team.id).pluck(:user_id) + notes = notes.where(author_id: user_ids) + else + user = User.find_by("LOWER(username) = LOWER(?)", from_value) + if user + notes = notes.where(author_id: user.id) + else + @warnings << "Unknown source '#{from_value}' for tag condition" + return relation + end + end + end + end + + # Apply added_before: condition (note creation time) + if added_before_cond + date = DateParser.new(added_before_cond[:value]).parse + notes = notes.where("notes.created_at < ?", date) if date + end + + # Apply added_after: condition (note creation time) + if added_after_cond + date = DateParser.new(added_after_cond[:value]).parse + notes = notes.where("notes.created_at >= ?", date) if date + end + + tagged_topic_ids = notes.select(:topic_id).distinct + + if negated + relation.where.not(id: tagged_topic_ids) + else + relation.where(id: tagged_topic_ids) + end + end + + # === Presence Selectors === + + def apply_has_selector(value, relation, negated:, conditions: nil) + normalized = value.to_s.downcase + + # Handle conditions for attachment and patch + if conditions.present? && %w[attachment patch].include?(normalized) + topic_ids = build_has_condition_query(normalized, conditions) + return negated ? relation.where.not(id: topic_ids) : relation.where(id: topic_ids) + end + + case normalized + when 'attachment' + topic_ids_subquery = Attachment.joins(:message).select('messages.topic_id').distinct + negated ? relation.where.not(id: topic_ids_subquery) : relation.where(id: topic_ids_subquery) + when 'patch' + topic_ids_subquery = Attachment.joins(:message) + .where("attachments.file_name ILIKE ? OR attachments.file_name ILIKE ?", "%.patch", "%.diff") + .select('messages.topic_id').distinct + negated ? relation.where.not(id: topic_ids_subquery) : relation.where(id: topic_ids_subquery) + when 'contributor' + # Use denormalized count + if negated + relation.where("topics.contributor_participant_count = 0") + else + relation.where("topics.contributor_participant_count > 0") + end + when 'committer' + committer_person_ids = ContributorMembership.where(contributor_type: 'committer').select(:person_id) + topic_ids_subquery = TopicParticipant.where(person_id: committer_person_ids).select(:topic_id).distinct + negated ? relation.where.not(id: topic_ids_subquery) : relation.where(id: topic_ids_subquery) + when 'core_team' + core_person_ids = ContributorMembership.where(contributor_type: 'core_team').select(:person_id) + topic_ids_subquery = TopicParticipant.where(person_id: core_person_ids).select(:topic_id).distinct + negated ? relation.where.not(id: topic_ids_subquery) : relation.where(id: topic_ids_subquery) + else + @warnings << "Unknown has: value '#{value}'" + relation + end + end + + def build_has_condition_query(has_type, conditions) + # Start with attachments joined to messages + base = Attachment.joins(:message) + + # For patches, filter by file extension + if has_type == 'patch' + base = base.where("attachments.file_name ILIKE ? OR attachments.file_name ILIKE ?", "%.patch", "%.diff") + end + + # Extract conditions + from_cond = conditions.find { |c| c[:key] == :from } + count_cond = conditions.find { |c| c[:key] == :count } + name_cond = conditions.find { |c| c[:key] == :name } + + # Apply from: condition + if from_cond + result = @value_resolver.resolve_author(from_cond[:value], quoted: from_cond[:quoted]) + @warnings.concat(result.warnings) + if result.person_ids.any? + base = base.where(messages: { sender_person_id: result.person_ids }) + end + end + + # Apply name: condition + if name_cond + pattern = "%#{sanitize_like(name_cond[:value])}%" + base = base.where("attachments.file_name ILIKE ?", pattern) + end + + # Apply count: condition - requires grouping + if count_cond + op, num = parse_count_value(count_cond[:value]) + if num + base.group('messages.topic_id') + .having("COUNT(*) #{op} ?", num) + .select('messages.topic_id') + else + base.select('messages.topic_id').distinct + end + else + base.select('messages.topic_id').distinct + end + end + + # === Count Selectors === + + def apply_count_selector(column, value, relation, negated:) + unless ALLOWED_COUNT_COLUMNS.include?(column) + @warnings << "Invalid count column: #{column}" + return relation + end + + operator, number = parse_count_value(value) + return relation unless number + + condition = case operator + when '>' then ["topics.#{column} > ?", number] + when '<' then ["topics.#{column} < ?", number] + when '>=' then ["topics.#{column} >= ?", number] + when '<=' then ["topics.#{column} <= ?", number] + else ["topics.#{column} = ?", number] + end + + if negated + relation.where.not(*condition) + else + relation.where(*condition) + end + end + + def parse_count_value(value) + match = value.to_s.match(/\A(>=|<=|>|<)?(\d+)\z/) + return [nil, nil] unless match + + [match[1] || '=', match[2].to_i] + end + + # === Date Selectors === + + def apply_date_selector(column, operator, value, relation, negated:) + unless ALLOWED_DATE_COLUMNS.include?(column) + @warnings << "Invalid date column: #{column}" + return relation + end + + date = DateParser.new(value).parse + return relation unless date + + # For negation, flip the operator + actual_operator = if negated + case operator + when :>= then :< + when :< then :>= + when :> then :<= + when :<= then :> + else operator + end + else + operator + end + + relation.where("topics.#{column} #{actual_operator} ?", date) + end + + def apply_messages_date_selector(operator, value, relation, negated:) + date = DateParser.new(value).parse + return relation unless date + + actual_operator = if negated + case operator + when :>= then :< + when :< then :>= + else operator + end + else + operator + end + + message_topic_ids = Message.where("messages.created_at #{actual_operator} ?", date).select(:topic_id) + relation.where(id: message_topic_ids) + end + + def apply_last_date_selector(operator, value, relation, negated:) + date = DateParser.new(value).parse + return relation unless date + + actual_operator = if negated + case operator + when :>= then :< + when :< then :>= + else operator + end + else + operator + end + + # Use the denormalized last_message_at column + relation.where("topics.last_message_at #{actual_operator} ?", date) + end + + # === Helpers === + + def sanitize_like(value) + ActiveRecord::Base.sanitize_sql_like(value.to_s) + end + + def sanitize_fts_query(value) + # plainto_tsquery and phraseto_tsquery handle text-to-tsquery conversion + # natively, so no operator injection is needed or desired. + value.to_s.strip + end + end +end diff --git a/app/services/search/query_parser.rb b/app/services/search/query_parser.rb new file mode 100644 index 0000000..79aa048 --- /dev/null +++ b/app/services/search/query_parser.rb @@ -0,0 +1,312 @@ +# frozen_string_literal: true + +require "parslet" + +module Search + # Parses search query strings into an AST using Parslet. + # Supports selectors (from:, title:, etc.), boolean operators (AND, OR), + # grouping with parentheses, and negation with minus prefix. + class QueryParser + class Grammar < Parslet::Parser + # Basic whitespace + rule(:space) { match('\s').repeat(1) } + rule(:space?) { space.maybe } + + # Quoted strings + rule(:double_quoted) do + str('"') >> + (str('\\') >> any | str('"').absent? >> any).repeat.as(:dq_content) >> + str('"') + end + + # Word characters (no spaces, parens, quotes, colons at end, no brackets for dependent conditions) + rule(:word_char) { match('[^\s()":,\[\]]') } + + # A word - sequence of word chars, may contain internal colons (like URLs) + rule(:word) do + (word_char >> (word_char | str(':') >> word_char.present?).repeat).as(:word) + end + + # Selector keywords - IMPORTANT: longer strings must come before shorter prefixes + # e.g., "reading" before "read", "messages_after" before "messages" + rule(:selector_key) do + ( + str('first_after') | str('first_before') | + str('messages_after') | str('messages_before') | + str('last_after') | str('last_before') | + str('last_from') | str('from') | str('starter') | + str('title') | str('body') | + str('contributors') | str('participants') | str('messages') | + str('unread') | str('reading') | str('read') | str('new') | + str('starred') | str('notes') | str('tag') | + str('has') + ).as(:selector_key) + end + + # Selector value (quoted or unquoted) + rule(:selector_value) do + double_quoted | word + end + + # Sub-condition keywords for dependent conditions within brackets + rule(:condition_key) do + ( + str('last_after') | str('last_before') | + str('first_after') | str('first_before') | + str('added_after') | str('added_before') | + str('messages') | str('count') | str('from') | + str('body') | str('name') + ).as(:condition_key) + end + + # Condition value - similar to selector_value but stops at comma or bracket + rule(:condition_value_char) { match('[^\s,\]":]') } + rule(:condition_value_word) do + (condition_value_char >> (condition_value_char | str(':') >> condition_value_char.present?).repeat).as(:cond_word) + end + rule(:condition_value) do + double_quoted | condition_value_word + end + + # Single condition: key:value + rule(:condition) do + condition_key >> str(':') >> condition_value.maybe.as(:condition_value) + end + + # Comma-separated condition list in brackets + rule(:dependent_conditions) do + str('[') >> space? >> + condition.as(:first_cond) >> + (space? >> str(',') >> space? >> condition).repeat.as(:more_conds) >> + space? >> str(']') + end + + # Full selector: key:value with optional dependent conditions + rule(:selector) do + selector_key >> str(':') >> selector_value.maybe.as(:selector_value) >> + dependent_conditions.maybe.as(:conditions) + end + + # Grouped expression + rule(:grouped) do + str('(') >> space? >> or_expression >> space? >> str(')') + end + + # Negation + rule(:neg) { str('-') } + + # Atomic term: selector, grouped, quoted text, or plain word + rule(:atom) do + selector.as(:selector) | + grouped | + double_quoted.as(:quoted_text) | + word.as(:plain_text) + end + + # Term can be negated + rule(:term) do + (neg.as(:negation) >> atom.as(:inner)).as(:negated) | + atom + end + + # AND operator (explicit only - implicit handled separately) + # Case-insensitive matching for boolean operators + rule(:and_keyword) { (str('A') | str('a')) >> (str('N') | str('n')) >> (str('D') | str('d')) } + rule(:or_keyword) { (str('O') | str('o')) >> (str('R') | str('r')) } + + # AND expression: terms joined by AND or space (implicit AND) + rule(:and_expression) do + (term.as(:first) >> + (space >> and_keyword >> space >> term | space >> or_keyword.absent? >> term).repeat.as(:more) + ).as(:and_sequence) + end + + # OR expression: and_expressions joined by OR + rule(:or_expression) do + (and_expression.as(:first) >> + (space >> or_keyword >> space >> and_expression).repeat.as(:more) + ).as(:or_sequence) + end + + # Root + rule(:query) { space? >> or_expression.maybe >> space? } + + root(:query) + end + + class Transform < Parslet::Transform + # Word to string + rule(word: simple(:w)) { w.to_s } + + # Condition value word to string + rule(cond_word: simple(:w)) { w.to_s } + + # Double-quoted content + rule(dq_content: simple(:c)) { { quoted_content: c.to_s } } + rule(dq_content: sequence(:c)) { { quoted_content: c.map(&:to_s).join } } + + # Plain text node + rule(plain_text: simple(:t)) do + { type: :text, value: t.to_s, negated: false, quoted: false } + end + + # Quoted text node + rule(quoted_text: { quoted_content: simple(:c) }) do + { type: :text, value: c.to_s, negated: false, quoted: true } + end + + # Selector with value and optional conditions + rule(selector: { selector_key: simple(:k), selector_value: subtree(:v), conditions: subtree(:conds) }) do + val = case v + when String then v + when Hash then v[:quoted_content] || v[:value] || "" + when nil then "" + else v.to_s + end + quoted = v.is_a?(Hash) && v.key?(:quoted_content) + + # Parse conditions if present + conditions = Transform.parse_conditions(conds) + + { + type: :selector, + key: k.to_s.to_sym, + value: val, + negated: false, + quoted: quoted, + conditions: conditions + } + end + + # Selector without conditions (backwards compatibility) + rule(selector: { selector_key: simple(:k), selector_value: subtree(:v) }) do + val = case v + when String then v + when Hash then v[:quoted_content] || v[:value] || "" + when nil then "" + else v.to_s + end + quoted = v.is_a?(Hash) && v.key?(:quoted_content) + { type: :selector, key: k.to_s.to_sym, value: val, negated: false, quoted: quoted, conditions: nil } + end + + # Selector without value + rule(selector: { selector_key: simple(:k), selector_value: nil }) do + { type: :selector, key: k.to_s.to_sym, value: "", negated: false, quoted: false, conditions: nil } + end + + # Helper to parse conditions from raw Parslet output + def self.parse_conditions(conds) + return nil if conds.nil? || conds == [] || conds == "" + + conditions = [] + + # Handle first_cond + if conds.is_a?(Hash) && conds[:first_cond] + conditions << parse_single_condition(conds[:first_cond]) + + # Handle more_conds + more = conds[:more_conds] + if more.is_a?(Array) + more.each do |c| + conditions << parse_single_condition(c) + end + elsif more.is_a?(Hash) + conditions << parse_single_condition(more) + end + end + + conditions.empty? ? nil : conditions + end + + def self.parse_single_condition(cond) + return nil unless cond.is_a?(Hash) + + key = cond[:condition_key]&.to_s&.to_sym + raw_value = cond[:condition_value] + + value = case raw_value + when String then raw_value + when Hash then raw_value[:quoted_content] || raw_value[:value] || "" + when nil then "" + else raw_value.to_s + end + + quoted = raw_value.is_a?(Hash) && raw_value.key?(:quoted_content) + + { key: key, value: value, quoted: quoted } + end + + # Negated term + rule(negated: { negation: simple(:_), inner: subtree(:inner) }) do + if inner.is_a?(Hash) && inner[:type] + inner.merge(negated: true) + else + { type: :text, value: inner.to_s, negated: true, quoted: false } + end + end + + # AND sequence + rule(and_sequence: { first: subtree(:first), more: subtree(:more) }) do + items = [first] + Array(more) + items = items.flatten.compact.reject { |x| x == {} || x == "" } + items.size == 1 ? items.first : { type: :and, children: items } + end + + # OR sequence + rule(or_sequence: { first: subtree(:first), more: subtree(:more) }) do + items = [first] + Array(more) + items = items.flatten.compact.reject { |x| x == {} || x == "" } + items.size == 1 ? items.first : { type: :or, children: items } + end + end + + def initialize + @grammar = Grammar.new + @transform = Transform.new + end + + def parse(query_string) + return nil if query_string.blank? + + tree = @grammar.parse(query_string) + ast = @transform.apply(tree) + + return nil if ast.nil? || ast == "" || ast == {} + + normalize_ast(ast) + end + + def valid?(query_string) + parse(query_string) + true + rescue Parslet::ParseFailed + false + end + + private + + def normalize_ast(node) + return node unless node.is_a?(Hash) + + if node[:type] == :and || node[:type] == :or + children = node[:children].map { |c| normalize_ast(c) } + children = children.flat_map do |c| + if c.is_a?(Hash) && c[:type] == node[:type] + c[:children] + else + [c] + end + end + children = children.compact.reject { |c| c == {} } + + return children.first if children.size == 1 + return nil if children.empty? + + node.merge(children: children) + else + node + end + end + end +end diff --git a/app/services/search/query_validator.rb b/app/services/search/query_validator.rb new file mode 100644 index 0000000..8d0df52 --- /dev/null +++ b/app/services/search/query_validator.rb @@ -0,0 +1,349 @@ +# frozen_string_literal: true + +module Search + # Validates AST nodes and collects warnings. + # Does not fail on invalid values - just warns and marks nodes to skip. + class QueryValidator + Result = Struct.new(:ast, :warnings, keyword_init: true) + + DATE_SELECTORS = %i[ + first_after first_before + messages_after messages_before + last_after last_before + ].freeze + + COUNT_SELECTORS = %i[messages participants contributors].freeze + + AUTHOR_SELECTORS = %i[from starter last_from].freeze + + STATE_SELECTORS = %i[unread read reading new starred notes].freeze + + CONTENT_SELECTORS = %i[title body].freeze + + TAG_SELECTORS = %i[tag].freeze + + ALL_SELECTORS = (DATE_SELECTORS + COUNT_SELECTORS + AUTHOR_SELECTORS + + STATE_SELECTORS + CONTENT_SELECTORS + TAG_SELECTORS + [:has]).freeze + + HAS_VALUES = %w[attachment patch contributor committer core_team].freeze + + # Valid sub-conditions for each parent selector + VALID_SUB_CONDITIONS = { + from: %i[messages last_before last_after first_before first_after body], + has: { + attachment: %i[from count name], + patch: %i[from count] + }, + tag: %i[from added_before added_after] + }.freeze + + # Sub-condition keywords that require date values + DATE_SUB_CONDITIONS = %i[last_before last_after first_before first_after added_before added_after].freeze + + # Sub-condition keywords that require count values + COUNT_SUB_CONDITIONS = %i[messages count].freeze + + def initialize(ast) + @ast = ast + @warnings = [] + end + + def validate + return Result.new(ast: nil, warnings: []) if @ast.nil? + + validated_ast = validate_node(@ast) + Result.new(ast: validated_ast, warnings: @warnings) + end + + private + + def validate_node(node) + return nil if node.nil? + + case node[:type] + when :and, :or + validate_compound(node) + when :selector + validate_selector(node) + when :text + validate_text(node) + else + node + end + end + + def validate_compound(node) + children = node[:children].map { |c| validate_node(c) }.compact + return nil if children.empty? + return children.first if children.size == 1 + + node.merge(children: children) + end + + def validate_selector(node) + key = node[:key] + value = node[:value].to_s + + # Check for empty value (except for selectors that support conditions only) + if value.blank? && !supports_empty_value_with_conditions?(key, node[:conditions]) + @warnings << "Empty value for '#{key}:' selector was ignored" + return nil + end + + # Validate based on selector type + validated = case key + when *DATE_SELECTORS + validate_date_selector(node) + when *COUNT_SELECTORS + validate_count_selector(node) + when *AUTHOR_SELECTORS + validate_author_selector(node) + when *STATE_SELECTORS + validate_state_selector(node) + when *CONTENT_SELECTORS + validate_content_selector(node) + when *TAG_SELECTORS + validate_tag_selector(node) + when :has + validate_has_selector(node) + else + @warnings << "Unknown selector '#{key}:' was ignored" + nil + end + + return nil unless validated + + # Validate conditions if present + if node[:conditions].present? + validated_conditions = validate_conditions(key, value, node[:conditions]) + return validated.merge(conditions: validated_conditions) + end + + validated + end + + def supports_empty_value_with_conditions?(key, conditions) + # tag: can have empty value with conditions (e.g., tag:[from:me]) + key == :tag && conditions.present? + end + + def validate_conditions(parent_key, parent_value, conditions) + valid_conditions = [] + + conditions.each do |cond| + validated = validate_single_condition(parent_key, parent_value, cond) + valid_conditions << validated if validated + end + + valid_conditions.empty? ? nil : valid_conditions + end + + def validate_single_condition(parent_key, parent_value, cond) + cond_key = cond[:key] + cond_value = cond[:value].to_s + + # Check if this condition is valid for the parent selector + valid_keys = get_valid_sub_conditions(parent_key, parent_value) + unless valid_keys.include?(cond_key) + @warnings << "Condition '#{cond_key}:' is not valid for '#{parent_key}:' selector - ignored" + return nil + end + + # Validate the condition value + if cond_value.blank? + @warnings << "Empty value for condition '#{cond_key}:' was ignored" + return nil + end + + # Validate based on condition type + if DATE_SUB_CONDITIONS.include?(cond_key) + parser = DateParser.new(cond_value) + unless parser.valid? + @warnings << "Invalid date '#{cond_value}' for condition '#{cond_key}:' was ignored" + return nil + end + elsif COUNT_SUB_CONDITIONS.include?(cond_key) + unless cond_value.match?(/\A(>|<|>=|<=)?(\d+)\z/) + @warnings << "Invalid count '#{cond_value}' for condition '#{cond_key}:' was ignored" + return nil + end + end + + cond + end + + def get_valid_sub_conditions(parent_key, parent_value) + case parent_key + when :from + VALID_SUB_CONDITIONS[:from] || [] + when :has + has_conditions = VALID_SUB_CONDITIONS[:has] + normalized_value = parent_value.to_s.downcase.to_sym + has_conditions[normalized_value] || [] + when :tag + VALID_SUB_CONDITIONS[:tag] || [] + else + [] + end + end + + def validate_date_selector(node) + value = node[:value] + parser = DateParser.new(value) + + unless parser.valid? + @warnings << "Invalid date '#{value}' for '#{node[:key]}:' was ignored" + return nil + end + + node + end + + def validate_count_selector(node) + value = node[:value].to_s + + # Parse count value: N, >N, =N, <=N + unless value.match?(/\A(>|<|>=|<=)?(\d+)\z/) + @warnings << "Invalid count '#{value}' for '#{node[:key]}:' was ignored" + return nil + end + + # Extract the number to check it's valid + number = value.gsub(/[<>=]/, '').to_i + if number < 0 + @warnings << "Negative count '#{value}' for '#{node[:key]}:' was ignored" + return nil + end + + node + end + + def validate_author_selector(node) + # Author selectors accept any non-empty value + # ValueResolver will handle the actual resolution and generate its own warnings + node + end + + def validate_state_selector(node) + # State selectors require 'me' or a team name + # Actual validation happens in ValueResolver + node + end + + def validate_content_selector(node) + # Content selectors (title:, body:) accept any non-empty value + # The value is passed to PostgreSQL FTS + node + end + + def validate_tag_selector(node) + value = node[:value].to_s + + # Tag value can be empty (with conditions like tag:[from:me]) or a simple tag name + # Tag names must match NoteTag format: starts with alphanumeric, can contain alphanumerics, _, ., - + if value.present? + tag_pattern = /\A[a-z0-9][a-z0-9_.\-]*\z/i + unless value.match?(tag_pattern) + @warnings << "Invalid tag name '#{value}' - tag names must start with alphanumeric" + return nil + end + end + + node + end + + def validate_has_selector(node) + value = node[:value].to_s.downcase + + unless HAS_VALUES.include?(value) + @warnings << "Unknown has: value '#{value}' - valid values are: #{HAS_VALUES.join(', ')}" + return nil + end + + node + end + + def validate_text(node) + value = node[:value].to_s + + # Don't check quoted text for selector typos - user explicitly quoted it + return node if node[:quoted] + + # Check if text looks like a selector (word:something) + if value.match?(/\A[a-z_]+:[^\s]*\z/i) + potential_key = value.split(':').first.downcase + check_for_selector_typo(potential_key, value) + end + + node + end + + def check_for_selector_typo(potential_key, full_value) + # Skip if it's a known selector (shouldn't happen, but safety check) + return if ALL_SELECTORS.include?(potential_key.to_sym) + + # Find similar selectors using Levenshtein-like matching + similar = find_similar_selectors(potential_key) + + if similar.any? + suggestions = similar.map { |s| "'#{s}:'" }.join(", ") + @warnings << "'#{full_value}' looks like a selector but '#{potential_key}:' is not recognized. " \ + "Did you mean #{suggestions}? It will be searched as plain text." + elsif looks_like_selector_typo?(potential_key) + # If it contains common selector-like patterns, warn generically + @warnings << "'#{potential_key}:' is not a recognized selector. " \ + "It will be searched as plain text. See search help for valid selectors." + end + end + + def find_similar_selectors(potential_key) + ALL_SELECTORS.select do |selector| + selector_str = selector.to_s + # Check for close match using simple edit distance heuristics + levenshtein_distance(potential_key, selector_str) <= 2 || + potential_key.include?(selector_str) || + selector_str.include?(potential_key) + end.map(&:to_s) + end + + def looks_like_selector_typo?(key) + # Common patterns that suggest user intended a selector + patterns = %w[ + from to by + after before + title body content text + read unread + star note tag + has message participant contributor + active sent started + ] + + patterns.any? { |p| key.include?(p) } + end + + def levenshtein_distance(s1, s2) + m, n = s1.length, s2.length + return n if m.zero? + return m if n.zero? + + # Use simple array instead of matrix for memory efficiency + prev_row = (0..n).to_a + curr_row = [] + + (1..m).each do |i| + curr_row[0] = i + (1..n).each do |j| + cost = s1[i - 1] == s2[j - 1] ? 0 : 1 + curr_row[j] = [ + prev_row[j] + 1, # deletion + curr_row[j - 1] + 1, # insertion + prev_row[j - 1] + cost # substitution + ].min + end + prev_row = curr_row.dup + end + + curr_row[n] + end + end +end diff --git a/app/services/search/value_resolver.rb b/app/services/search/value_resolver.rb new file mode 100644 index 0000000..1dc6151 --- /dev/null +++ b/app/services/search/value_resolver.rb @@ -0,0 +1,224 @@ +# frozen_string_literal: true + +module Search + # Resolves special values in search queries to actual database IDs. + # Handles: me, team names, contributor types, email/name detection + class ValueResolver + CONTRIBUTOR_TYPES = %w[ + contributor + committer + core_team + major_contributor + significant_contributor + past_major_contributor + past_significant_contributor + ].freeze + + # Result types for author resolution + Result = Struct.new(:type, :person_ids, :alias_ids, :user_ids, :warnings, keyword_init: true) do + def self.empty(warning: nil) + new(type: :empty, person_ids: [], alias_ids: [], user_ids: [], warnings: Array(warning)) + end + + def self.persons(ids, warnings: []) + new(type: :persons, person_ids: Array(ids), alias_ids: [], user_ids: [], warnings: warnings) + end + + def self.aliases(ids, warnings: []) + new(type: :aliases, person_ids: [], alias_ids: Array(ids), user_ids: [], warnings: warnings) + end + + def self.users(ids, warnings: []) + new(type: :users, person_ids: [], alias_ids: [], user_ids: Array(ids), warnings: warnings) + end + end + + # Result type for tag resolution + TagResult = Struct.new(:tag_name, :user_ids, :warnings, keyword_init: true) do + def self.empty(warning: nil) + new(tag_name: nil, user_ids: nil, warnings: Array(warning)) + end + end + + def initialize(user:) + @user = user + end + + # Resolve author value (from:, starter:, last_from:) + # Returns Result with person_ids or alias_ids + def resolve_author(value, quoted: false) + return Result.empty(warning: "Empty author value") if value.blank? + + # Check special values in order of priority + return resolve_me_author if value == "me" + return resolve_contributor_type(value) if contributor_type?(value) + + team_result = resolve_team_author(value) + return team_result if team_result + + # Fall back to name/email search + resolve_name_or_email(value, quoted: quoted) + end + + # Resolve state value (unread:, starred:, notes:, etc.) + # Returns Result with user_ids + def resolve_state_subject(value) + return Result.empty(warning: "Empty state value") if value.blank? + + if value == "me" + return Result.empty(warning: "Must be signed in to use 'me'") unless @user + return Result.users([@user.id]) + end + + # Check for team (must be member for state selectors due to privacy) + team = find_team_for_state(value) + if team + user_ids = team_member_user_ids(team) + return Result.users(user_ids) + end + + Result.empty(warning: "Invalid state subject: #{value}") + end + + # Resolve tag value (tag:) + # Returns TagResult with tag_name (simple tag names only, no @ syntax) + def resolve_tag(value) + return TagResult.empty(warning: "Empty tag value") if value.blank? + return TagResult.empty(warning: "Must be signed in to use tag search") unless @user + + # Simple tag name - all accessible sources + TagResult.new(tag_name: value.downcase, user_ids: nil, warnings: []) + end + + # Check if value is a contributor type keyword + def contributor_type?(value) + CONTRIBUTOR_TYPES.include?(value.to_s.downcase) + end + + # Check if value contains @ (email indicator) + def email_value?(value) + value.to_s.include?("@") + end + + private + + def resolve_me_author + return Result.empty(warning: "Must be signed in to use 'me'") unless @user + + person_id = @user.person_id + return Result.empty(warning: "User has no associated person") unless person_id + + Result.persons([person_id]) + end + + def resolve_contributor_type(value) + normalized = value.to_s.downcase + + person_ids = if normalized == "contributor" + # Match any contributor type + ContributorMembership.distinct.pluck(:person_id) + else + # Match specific contributor type + ContributorMembership.where(contributor_type: normalized).pluck(:person_id) + end + + if person_ids.empty? + Result.empty(warning: "No contributors found for type: #{value}") + else + Result.persons(person_ids) + end + end + + def resolve_team_author(value) + team = find_accessible_team(value) + return nil unless team + + # Get all person_ids for team members + person_ids = User.joins(:team_members) + .where(team_members: { team_id: team.id }) + .pluck(:person_id) + .compact + + if person_ids.empty? + Result.empty(warning: "Team '#{value}' has no members") + else + Result.persons(person_ids) + end + end + + def resolve_name_or_email(value, quoted:) + if email_value?(value) + # Value contains @ - search emails only + resolve_email(value, quoted: quoted) + else + # No @ - search both name and email + resolve_name_and_email(value, quoted: quoted) + end + end + + def resolve_email(value, quoted:) + aliases = if quoted + Alias.where("LOWER(email) = LOWER(?)", value) + else + Alias.where("email ILIKE ?", "%#{sanitize_like(value)}%") + end + + person_ids = aliases.where.not(person_id: nil).distinct.pluck(:person_id) + + if person_ids.empty? + Result.empty(warning: "No matching email found: #{value}") + else + Result.persons(person_ids) + end + end + + def resolve_name_and_email(value, quoted:) + aliases = if quoted + Alias.where("LOWER(name) = LOWER(?) OR LOWER(email) = LOWER(?)", value, value) + else + pattern = "%#{sanitize_like(value)}%" + Alias.where("name ILIKE ? OR email ILIKE ?", pattern, pattern) + end + + person_ids = aliases.where.not(person_id: nil).distinct.pluck(:person_id) + + if person_ids.empty? + Result.empty(warning: "No matching author found: #{value}") + else + Result.persons(person_ids) + end + end + + # Find a team that the user can access for author queries (from:, starter:) + # Visible/open teams are accessible to everyone + # Private teams require membership + def find_accessible_team(name) + # First check if it's a team at all + team = Team.find_by("LOWER(name) = LOWER(?)", name) + return nil unless team + + # Check accessibility + return team if team.accessible_to?(@user) + + nil + end + + # Find a team for state queries (unread:, starred:, notes:) + # Only accessible if user is a member (privacy requirement) + def find_team_for_state(name) + return nil unless @user + + Team.joins(:team_members) + .where(team_members: { user_id: @user.id }) + .find_by("LOWER(teams.name) = LOWER(?)", name) + end + + def team_member_user_ids(team) + TeamMember.where(team_id: team.id).pluck(:user_id) + end + + def sanitize_like(value) + ActiveRecord::Base.sanitize_sql_like(value) + end + end +end diff --git a/app/views/help/pages/search.md b/app/views/help/pages/search.md new file mode 100644 index 0000000..c9709aa --- /dev/null +++ b/app/views/help/pages/search.md @@ -0,0 +1,509 @@ +# Advanced Search Guide + +Hackorum's search supports powerful query syntax that lets you find exactly what you're looking for in the PostgreSQL Hackers mailing list archive. This guide covers all available search operators and shows you how to combine them for precise results. + +## Quick Start + +The simplest search is just typing keywords: + +``` +vacuum performance +``` + +This finds topics containing both "vacuum" AND "performance" in the title or message body. + +For phrases, use quotes: + +``` +"query planning" +``` + +## Search Selectors + +Selectors let you search specific fields. The format is `selector:value`. + +### Content Selectors + +| Selector | Description | Example | +|----------|-------------|---------| +| `title:` | Search in topic titles only | `title:vacuum` | +| `body:` | Search in message bodies only | `body:"shared buffers"` | + +Without a selector, plain text searches both title and body. + +### Author Selectors + +| Selector | Description | Example | +|----------|-------------|---------| +| `from:` | Topics with messages from author | `from:andres` | +| `starter:` | Topics started by author | `starter:heikki` | +| `last_from:` | Topics where author sent last message | `last_from:robert` | + +#### Special Author Values + +- **`me`** - Your own messages (requires sign-in) +- **Team names** - Messages from any team member (e.g., `from:core-reviewers`) +- **Contributor types** - Messages from PostgreSQL contributors: + - `contributor` - Any contributor + - `committer` - PostgreSQL committers + - `core_team` - Core team members + - `major_contributor` - Major contributors + - `significant_contributor` - Significant contributors + +``` +from:me # Your messages +from:committer # Messages from any committer +starter:core_team # Topics started by core team members +``` + +#### Email vs Name Search + +If your search value contains `@`, it searches email addresses only: + +``` +from:peter@example.org # Partial email match (finds any email containing this) +from:peter # Searches both name and email (partial match) +``` + +Use quotes for exact matches: + +``` +from:"peter@example.org" # Exact email match +from:"Peter Eisentraut" # Exact name match +``` + +### Date Selectors + +| Selector | Description | Example | +|----------|-------------|---------| +| `first_after:` | Topics created after date | `first_after:2024-01-01` | +| `first_before:` | Topics created before date | `first_before:2023` | +| `messages_after:` | Topics with messages after date | `messages_after:1w` | +| `messages_before:` | Topics with messages before date | `messages_before:2024-06` | +| `last_after:` | Topics with last activity after date | `last_after:today` | +| `last_before:` | Topics with last activity before date | `last_before:yesterday` | + +#### Date Formats + +**Absolute dates:** +- Full date: `2024-01-15` +- Month: `2024-01` (first day of month) +- Year: `2024` (January 1st) +- ISO timestamp: `2024-01-15T10:30:00` + +**Relative dates:** +- `today` - Today +- `yesterday` - Yesterday +- `7d` - 7 days ago +- `2w` - 2 weeks ago +- `3m` - 3 months ago (approximately 90 days) +- `1y` - 1 year ago (approximately 365 days) + +``` +first_after:2024 # Topics started in 2024 or later +last_after:1w # Topics with activity in the last week +messages_before:yesterday # Messages sent before yesterday +``` + +### Count Selectors + +| Selector | Description | Example | +|----------|-------------|---------| +| `messages:` | Filter by message count | `messages:>10` | +| `participants:` | Filter by participant count | `participants:>=5` | +| `contributors:` | Filter by contributor count | `contributors:>0` | + +#### Count Operators + +- `messages:10` - Exactly 10 messages +- `messages:>10` - More than 10 messages +- `messages:<10` - Fewer than 10 messages +- `messages:>=10` - 10 or more messages +- `messages:<=10` - 10 or fewer messages + +``` +messages:>50 participants:>10 # Large, active discussions +messages:1 has:patch # Single-message patch submissions +``` + +### Presence Selectors + +The `has:` selector checks for the presence of specific attributes: + +| Value | Description | +|-------|-------------| +| `has:attachment` | Topics with any attachments | +| `has:patch` | Topics with `.patch` or `.diff` files | +| `has:contributor` | Topics with PostgreSQL contributor activity | +| `has:committer` | Topics with committer activity | +| `has:core_team` | Topics with core team activity | + +``` +has:patch first_after:1m # Recent topics with patches +has:contributor messages:>5 # Active discussions with contributor input +``` + +### Personal State Selectors + +These require being signed in and filter based on your reading state: + +| Selector | Description | +|----------|-------------| +| `unread:me` | Topics with unread messages | +| `read:me` | Topics you've fully read | +| `reading:me` | Topics you've partially read | +| `new:me` | Topics you've never seen | +| `starred:me` | Topics you've starred | +| `notes:me` | Topics where you've added notes | + +#### Team State Selectors + +Replace `me` with a team name to filter by team state (you must be a team member): + +``` +unread:core-reviewers # Topics unread by anyone on the team +starred:review-team # Topics starred by any team member +``` + +### Tag Selectors + +Search for topics by tags added to notes. Tags are private to the note author and mentioned users/teams. + +| Selector | Description | Example | +|----------|-------------|---------| +| `tag:tagname` | Topics with this tag (from any accessible note) | `tag:needs-review` | +| `tag:tagname[from:me]` | Topics with this tag from your own notes | `tag:important[from:me]` | +| `tag:tagname[from:team]` | Topics with this tag from team member notes | `tag:follow-up[from:reviewers]` | +| `tag:[from:me]` | Topics with any tag from your notes | `tag:[from:me]` | +| `tag:[from:team]` | Topics with any tag from team's notes | `tag:[from:reviewers]` | + +Use the `[from:]` condition to filter by who created the tag: +- `from:me` - Your own tags +- `from:teamname` - Tags from any team member (you must be a team member) +- `from:username` - Tags from a specific user + +``` +tag:blocked # Topics tagged "blocked" by anyone you can see +tag:review-needed[from:me] # Topics I tagged "review-needed" +tag:priority[from:core_team] # Topics tagged "priority" by core_team members +tag:[from:me] # Any topics I've tagged +-tag:done # Exclude topics tagged "done" +tag:important[from:me] unread:me # My important tags that are unread +``` + +## Dependent Conditions (Advanced) + +Some selectors support **dependent conditions** using bracket notation. These sub-conditions apply specifically to the entity matched by the parent selector, rather than to the topic globally. + +### Understanding the Difference + +**Without dependent conditions:** +``` +from:andres messages:>=10 +``` +This finds topics where Andres posted AND the topic has 10+ total messages (from anyone). + +**With dependent conditions:** +``` +from:andres[messages:>=10] +``` +This finds topics where Andres *specifically* posted 10 or more messages. + +### Syntax + +Dependent conditions use brackets after the selector value, with comma-separated conditions inside: + +``` +selector:value[condition1:value1, condition2:value2] +``` + +### from: Conditions + +Filter by the author's specific activity within a topic: + +| Condition | Description | Example | +|-----------|-------------|---------| +| `messages:` | Author's message count | `from:andres[messages:>=10]` | +| `last_before:` | Author's last message before date | `from:heikki[last_before:1m]` | +| `last_after:` | Author's last message after date | `from:magnus[last_after:1w]` | +| `first_before:` | Author's first message before date | `from:michael[first_before:2024]` | +| `first_after:` | Author's first message after date | `from:amit[first_after:2024-01-01]` | +| `body:` | Author posted content matching | `from:alvaro[body:"patch"]` | + +#### Examples + +``` +from:andres[messages:>=10] # Andres posted 10+ messages +from:heikki[last_before:1m] # Heikki hasn't posted in 1 month +from:thomas[body:"LGTM"] # Thomas posted containing "LGTM" +from:core_team[messages:>=5] # Team posted 5+ combined messages +from:magnus[messages:>=3, last_after:1w] # Magnus: 3+ msgs and recent activity +``` + +#### Team Behavior + +When using a team name with dependent conditions: + +- **Message counts are aggregated**: `from:team[messages:>=10]` matches if team members combined posted 10+ messages +- **Date conditions apply per-member**: `from:team[last_before:1m]` matches topics where team members who posted have been inactive for 1 month + +### has:attachment Conditions + +Filter attachments by author or count: + +| Condition | Description | Example | +|-----------|-------------|---------| +| `from:` | Attachments from author | `has:attachment[from:nathan]` | +| `count:` | Number of attachments | `has:attachment[count:>=3]` | +| `name:` | Attachment filename contains | `has:attachment[name:v2]` | + +#### Examples + +``` +has:attachment[from:nathan] # Attachments from Nathan +has:attachment[count:>=5] # 5+ attachments total +has:attachment[from:peter,count:>=3] # 3+ attachments from Peter +has:attachment[name:patch] # Attachments with "patch" in name +``` + +### has:patch Conditions + +Filter patch files by author or count: + +| Condition | Description | Example | +|-----------|-------------|---------| +| `from:` | Patches from author | `has:patch[from:michael]` | +| `count:` | Number of patches | `has:patch[count:>=2]` | + +#### Examples + +``` +has:patch[from:michael] # Patches from Michael +has:patch[count:>=3] # 3+ patch files +has:patch[from:committer] # Patches from any committer +``` + +### tag: Conditions + +Filter tags by author or when added: + +| Condition | Description | Example | +|-----------|-------------|---------| +| `from:` | Tag added by source | `tag:review[from:me]` | +| `added_before:` | Tag added before date | `tag:important[added_before:1w]` | +| `added_after:` | Tag added after date | `tag:urgent[added_after:yesterday]` | + +#### Examples + +``` +tag:review[from:me] # "review" tag from me +tag:[from:me] # Any tag from me +tag:important[added_after:1w] # "important" tags added this week +tag:blocked[from:core_team] # "blocked" by core team +tag:review[from:me, added_after:1m] # My recent "review" tags +``` + +### Combining with Other Selectors + +Dependent conditions work with all other search features: + +``` +from:andres[messages:>=10] has:patch # Andres posted 10+ msgs AND has patches +has:attachment[from:amit] unread:me # Amit's attachments I haven't read +from:committer[last_before:1m] -has:contributor # Inactive committers +(from:alvaro[body:patch] OR has:patch[from:alvaro]) first_after:1m +``` + +### Negation + +You can negate selectors with dependent conditions: + +``` +-from:heikki[messages:>=10] # Topics where Heikki did NOT post 10+ msgs +-has:attachment[from:bot] # No attachments from bot +``` + +## Boolean Operators + +### AND (Implicit and Explicit) + +Terms separated by spaces are combined with AND: + +``` +vacuum autovacuum # Both terms must match +``` + +You can also use explicit `AND`: + +``` +vacuum AND autovacuum # Same as above +``` + +### OR + +Use `OR` to match either term: + +``` +vacuum OR autovacuum # Either term matches +from:robert OR from:thomas # Messages from either author +``` + +### Operator Precedence + +AND binds more tightly than OR. This query: + +``` +from:robert unread:me OR from:thomas +``` + +Is interpreted as: + +``` +(from:robert AND unread:me) OR from:thomas +``` + +## Grouping with Parentheses + +Use parentheses to control grouping: + +``` +(from:robert OR from:thomas) unread:me +``` + +This finds unread topics from either Robert or Thomas. + +More complex example: + +``` +(has:patch OR has:attachment) first_after:1m -has:contributor +``` + +This finds recent topics with patches or attachments that haven't received contributor attention. + +## Negation + +Prefix any term or selector with `-` to exclude matches: + +``` +-from:bot # Exclude bot messages +vacuum -autovacuum # Vacuum but not autovacuum +-has:contributor # No contributor activity +``` + +Negate grouped expressions: + +``` +-(from:tom OR from:bruce) # Exclude both authors +``` + +## Full-Text Search + +Text searches use PostgreSQL's full-text search with English stemming: + +- `running` matches "run", "runs", "running" +- `databases` matches "database", "databases" +- Common words (stop words) like "the", "a", "is" are ignored + +For exact phrase matching, use quotes: + +``` +"shared buffers" # Exact phrase +title:"query planning" # Exact phrase in title +``` + +## Example Queries + +### Finding Unanswered Patch Submissions + +``` +has:patch messages:1 first_after:1m +``` + +Single-message topics with patches submitted in the last month. + +### Your Team's Reading Queue + +``` +unread:my-team has:contributor first_after:2w +``` + +Recent topics with contributor activity that no team member has read. + +### Researching a Topic + +``` +"logical replication" from:committer first_after:2023 +``` + +Discussions about logical replication from committers since 2023. + +### Finding Your Contributions + +``` +from:me first_after:1y +``` + +Topics where you've participated in the last year. + +### Active Discussions Without Resolution + +``` +messages:>20 last_after:1w -has:committer +``` + +Long discussions with recent activity but no committer involvement. + +### Patch Reviews Needed + +``` +has:patch -has:contributor first_after:2w participants:<3 +``` + +Recent patches that haven't received contributor attention and have few participants. + +### Active Contributors Who've Gone Quiet + +``` +from:committer[last_before:1m, messages:>=5] +``` + +Topics where a committer was actively participating (5+ messages) but hasn't posted in over a month. + +### Patches from Specific Author + +``` +has:patch[from:nathan,count:>=2] first_after:1m +``` + +Recent topics with multiple patches submitted by Nathan. + +### Finding Your Tagged Reviews + +``` +tag:needs-review[from:me, added_after:1w] -read:me +``` + +Topics you tagged "needs-review" in the last week that you haven't fully read. + +## Selector Reference + +| Category | Selectors | +|----------|-----------| +| **Content** | `title:`, `body:` | +| **Author** | `from:`*, `starter:`, `last_from:` | +| **Dates** | `first_after:`, `first_before:`, `messages_after:`, `messages_before:`, `last_after:`, `last_before:` | +| **Counts** | `messages:`, `participants:`, `contributors:` | +| **Presence** | `has:attachment`*, `has:patch`*, `has:contributor`, `has:committer`, `has:core_team` | +| **State** | `unread:`, `read:`, `reading:`, `new:`, `starred:`, `notes:` | +| **Tags** | `tag:tagname`*, `tag:[from:source]` | + +*Supports [dependent conditions](#dependent-conditions-advanced) + +### Dependent Condition Reference + +| Parent Selector | Available Conditions | +|-----------------|---------------------| +| `from:` | `messages:`, `last_before:`, `last_after:`, `first_before:`, `first_after:`, `body:` | +| `has:attachment` | `from:`, `count:`, `name:` | +| `has:patch` | `from:`, `count:` | +| `tag:` | `from:`, `added_before:`, `added_after:` | diff --git a/app/views/topics/_sidebar.html.slim b/app/views/topics/_sidebar.html.slim index 65f9bd1..baadb96 100644 --- a/app/views/topics/_sidebar.html.slim +++ b/app/views/topics/_sidebar.html.slim @@ -18,6 +18,10 @@ = form_with url: search_topics_path, method: :get, local: true do |f| = f.text_field :q, placeholder: "Search topics and messages...", class: "search-input", value: search_query, data: { "search-focus-target": "input" } = f.submit "Search", class: "search-button" + .search-help-link + = link_to help_path("search") do + i.fas.fa-question-circle + | Search syntax help .sidebar-section h3.sidebar-heading Quick filters diff --git a/app/views/topics/search.html.slim b/app/views/topics/search.html.slim index 277d85f..2c12132 100644 --- a/app/views/topics/search.html.slim +++ b/app/views/topics/search.html.slim @@ -5,6 +5,27 @@ #new-topics-banner data-controller="new-topics-banner" data-new-topics-banner-url-value=new_topics_count_topics_path(q: @search_query, viewing_since: @viewing_since.iso8601) data-new-topics-banner-interval-ms-value="180000" = render partial: "new_topics_banner", locals: { count: @new_topics_count, viewing_since: @viewing_since, refresh_path: search_topics_path(q: @search_query) } +- if @search_error.present? + .search-error + .search-error-icon + i.fas.fa-exclamation-triangle + .search-error-content + h3 Search Error + p = @search_error + p.search-error-help + | Check your query syntax or see the + =< link_to "search help", help_path("search") + | for examples. + +- elsif @search_warnings.present? + .search-warnings + .search-warnings-header + i.fas.fa-info-circle + span Some parts of your search were adjusted: + ul.search-warnings-list + - @search_warnings.each do |warning| + li = warning + - if @search_query.present? - if @topics.any? - if user_signed_in? diff --git a/db/migrate/20260206173214_add_fts_tsvector_columns.rb b/db/migrate/20260206173214_add_fts_tsvector_columns.rb new file mode 100644 index 0000000..fc7b618 --- /dev/null +++ b/db/migrate/20260206173214_add_fts_tsvector_columns.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +class AddFtsTsvectorColumns < ActiveRecord::Migration[8.0] + def up + # Add generated tsvector column for topics.title + execute <<-SQL + ALTER TABLE topics + ADD COLUMN title_tsv tsvector + GENERATED ALWAYS AS (to_tsvector('english', COALESCE(title, ''))) STORED; + SQL + + add_index :topics, :title_tsv, using: :gin + + # Add generated tsvector column for messages.body + execute <<-SQL + ALTER TABLE messages + ADD COLUMN body_tsv tsvector + GENERATED ALWAYS AS (to_tsvector('english', COALESCE(body, ''))) STORED; + SQL + + add_index :messages, :body_tsv, using: :gin + end + + def down + remove_index :messages, :body_tsv, if_exists: true + execute "ALTER TABLE messages DROP COLUMN IF EXISTS body_tsv" + + remove_index :topics, :title_tsv, if_exists: true + execute "ALTER TABLE topics DROP COLUMN IF EXISTS title_tsv" + end +end diff --git a/db/schema.rb b/db/schema.rb index 7ee5f41..7d0be74 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.0].define(version: 2026_02_05_191041) do +ActiveRecord::Schema[8.0].define(version: 2026_02_06_173214) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" enable_extension "pg_stat_statements" @@ -258,7 +258,9 @@ t.datetime "updated_at", null: false t.bigint "sender_person_id", null: false t.string "reply_to_message_id" + t.virtual "body_tsv", type: :tsvector, as: "to_tsvector('english'::regconfig, COALESCE(body, ''::text))", stored: true t.index ["body"], name: "index_messages_on_body_trgm", opclass: :gin_trgm_ops, using: :gin + t.index ["body_tsv"], name: "index_messages_on_body_tsv", using: :gin t.index ["created_at", "sender_id"], name: "index_messages_on_created_at_and_sender_id" t.index ["created_at", "topic_id"], name: "index_messages_on_created_at_and_topic_id" t.index ["created_at"], name: "index_messages_on_created_at" @@ -622,12 +624,14 @@ t.boolean "has_attachments", default: false, null: false t.bigint "last_message_id" t.bigint "merged_into_topic_id" + t.virtual "title_tsv", type: :tsvector, as: "to_tsvector('english'::regconfig, (COALESCE(title, ''::character varying))::text)", stored: true t.index ["created_at"], name: "index_topics_on_created_at" t.index ["creator_id"], name: "index_topics_on_creator_id" t.index ["creator_person_id"], name: "index_topics_on_creator_person_id" t.index ["last_message_at"], name: "index_topics_on_last_message_at" t.index ["merged_into_topic_id"], name: "index_topics_on_merged_into_topic_id" t.index ["title"], name: "index_topics_on_title_trgm", opclass: :gin_trgm_ops, using: :gin + t.index ["title_tsv"], name: "index_topics_on_title_tsv", using: :gin end create_table "user_tokens", force: :cascade do |t| diff --git a/spec/factories/note_mentions.rb b/spec/factories/note_mentions.rb new file mode 100644 index 0000000..34e1257 --- /dev/null +++ b/spec/factories/note_mentions.rb @@ -0,0 +1,6 @@ +FactoryBot.define do + factory :note_mention do + association :note + association :mentionable, factory: :user + end +end diff --git a/spec/factories/note_tags.rb b/spec/factories/note_tags.rb new file mode 100644 index 0000000..a9e9dfb --- /dev/null +++ b/spec/factories/note_tags.rb @@ -0,0 +1,6 @@ +FactoryBot.define do + factory :note_tag do + association :note + tag { "test-tag" } + end +end diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb index 4d5ee8c..6a523b1 100644 --- a/spec/rails_helper.rb +++ b/spec/rails_helper.rb @@ -19,8 +19,9 @@ config.use_transactional_fixtures = true config.infer_spec_type_from_file_location! config.filter_rails_from_backtrace! - + config.include FactoryBot::Syntax::Methods + config.include ActiveSupport::Testing::TimeHelpers end Shoulda::Matchers.configure do |config| diff --git a/spec/services/search/date_parser_spec.rb b/spec/services/search/date_parser_spec.rb new file mode 100644 index 0000000..8c203bc --- /dev/null +++ b/spec/services/search/date_parser_spec.rb @@ -0,0 +1,97 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Search::DateParser, type: :service do + describe '#parse' do + context 'with absolute dates' do + it 'parses full date format (YYYY-MM-DD)' do + result = described_class.new('2024-01-15').parse + expect(result).to eq(Time.zone.parse('2024-01-15 00:00:00')) + end + + it 'parses month format (YYYY-MM)' do + result = described_class.new('2024-01').parse + expect(result).to eq(Time.zone.parse('2024-01-01 00:00:00')) + end + + it 'parses year format (YYYY)' do + result = described_class.new('2024').parse + expect(result).to eq(Time.zone.parse('2024-01-01 00:00:00')) + end + + it 'parses ISO timestamp' do + result = described_class.new('2024-01-15T10:30:00').parse + expect(result).to eq(Time.zone.parse('2024-01-15 10:30:00')) + end + end + + context 'with relative dates' do + around do |example| + travel_to(Time.zone.parse('2024-06-15 12:00:00')) { example.run } + end + + it 'parses today' do + result = described_class.new('today').parse + expect(result).to eq(Time.zone.parse('2024-06-15 00:00:00')) + end + + it 'parses yesterday' do + result = described_class.new('yesterday').parse + expect(result).to eq(Time.zone.parse('2024-06-14 00:00:00')) + end + + it 'parses days ago (7d)' do + result = described_class.new('7d').parse + expect(result).to be_within(1.second).of(7.days.ago) + end + + it 'parses weeks ago (2w)' do + result = described_class.new('2w').parse + expect(result).to be_within(1.second).of(2.weeks.ago) + end + + it 'parses months ago (3m)' do + result = described_class.new('3m').parse + expect(result).to be_within(1.second).of(90.days.ago) + end + + it 'parses years ago (1y)' do + result = described_class.new('1y').parse + expect(result).to be_within(1.second).of(365.days.ago) + end + + it 'is case insensitive for relative dates' do + expect(described_class.new('TODAY').parse).to eq(described_class.new('today').parse) + expect(described_class.new('7D').parse).to be_within(1.second).of(described_class.new('7d').parse) + end + end + + context 'with invalid dates' do + it 'returns nil for blank value' do + expect(described_class.new('').parse).to be_nil + expect(described_class.new(' ').parse).to be_nil + expect(described_class.new(nil).parse).to be_nil + end + + it 'returns nil for invalid format' do + expect(described_class.new('notadate').parse).to be_nil + expect(described_class.new('2024-13-01').parse).to be_nil + expect(described_class.new('invalid').parse).to be_nil + end + end + end + + describe '#valid?' do + it 'returns true for valid dates' do + expect(described_class.new('2024-01-15').valid?).to be true + expect(described_class.new('today').valid?).to be true + expect(described_class.new('7d').valid?).to be true + end + + it 'returns false for invalid dates' do + expect(described_class.new('').valid?).to be false + expect(described_class.new('notadate').valid?).to be false + end + end +end diff --git a/spec/services/search/integration_spec.rb b/spec/services/search/integration_spec.rb new file mode 100644 index 0000000..808e528 --- /dev/null +++ b/spec/services/search/integration_spec.rb @@ -0,0 +1,240 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Search Integration', type: :service do + let(:person) { create(:person) } + let(:user) { create(:user, person: person) } + + def search(query_string, user: nil) + parser = Search::QueryParser.new + ast = parser.parse(query_string) + validator = Search::QueryValidator.new(ast) + validated = validator.validate + builder = Search::QueryBuilder.new(ast: validated.ast, user: user) + result = builder.build + + { + topics: result.relation.to_a, + warnings: validated.warnings + result.warnings + } + end + + describe 'end-to-end query processing' do + let!(:postgresql_topic) do + topic = create(:topic, title: 'PostgreSQL Performance Guide') + create(:message, topic: topic, body: 'Tips for optimizing PostgreSQL queries') + topic.update_denormalized_counts! + topic + end + + let!(:mysql_topic) do + topic = create(:topic, title: 'MySQL vs PostgreSQL') + create(:message, topic: topic, body: 'Comparison of database systems') + topic.update_denormalized_counts! + topic + end + + it 'finds topics by title keyword' do + result = search('performance', user: user) + expect(result[:topics]).to include(postgresql_topic) + expect(result[:topics]).not_to include(mysql_topic) + end + + it 'finds topics by body keyword' do + result = search('optimizing', user: user) + expect(result[:topics]).to include(postgresql_topic) + end + + it 'handles complex queries with multiple conditions' do + result = search('postgresql -mysql', user: user) + expect(result[:topics]).to include(postgresql_topic) + # mysql_topic has "PostgreSQL" in title too + end + end + + describe 'author-based queries' do + let(:john_person) { create(:person) } + let(:john_alias) { create(:alias, name: 'John Smith', email: 'john@postgresql.org', person: john_person) } + + let!(:topic_from_john) do + topic = create(:topic, title: 'RFC: New Feature') + create(:message, topic: topic, sender: john_alias, sender_person_id: john_person.id) + topic.update_denormalized_counts! + topic + end + + let!(:topic_not_from_john) do + topic = create(:topic, title: 'Another Discussion') + create(:message, topic: topic) + topic.update_denormalized_counts! + topic + end + + it 'finds topics by author name' do + result = search('from:john', user: user) + expect(result[:topics]).to include(topic_from_john) + expect(result[:topics]).not_to include(topic_not_from_john) + end + + it 'finds topics by author email' do + result = search('from:john@postgresql.org', user: user) + expect(result[:topics]).to include(topic_from_john) + end + + it 'finds topics with OR between authors' do + jane_person = create(:person) + jane_alias = create(:alias, name: 'Jane Doe', person: jane_person) + topic_from_jane = create(:topic, title: 'Jane Topic') + create(:message, topic: topic_from_jane, sender: jane_alias, sender_person_id: jane_person.id) + topic_from_jane.update_denormalized_counts! + + result = search('from:john OR from:jane', user: user) + expect(result[:topics]).to include(topic_from_john, topic_from_jane) + expect(result[:topics]).not_to include(topic_not_from_john) + end + end + + describe 'date-based queries' do + let!(:recent_topic) do + topic = create(:topic, title: 'Recent Topic', created_at: 3.days.ago) + create(:message, topic: topic, created_at: 3.days.ago) + topic.update_denormalized_counts! + topic + end + + let!(:old_topic) do + topic = create(:topic, title: 'Old Topic', created_at: 3.months.ago) + create(:message, topic: topic, created_at: 3.months.ago) + topic.update_denormalized_counts! + topic + end + + it 'finds recent topics with first_after' do + result = search('first_after:1w', user: user) + expect(result[:topics]).to include(recent_topic) + expect(result[:topics]).not_to include(old_topic) + end + + it 'finds old topics with first_before' do + result = search('first_before:1m', user: user) + expect(result[:topics]).to include(old_topic) + expect(result[:topics]).not_to include(recent_topic) + end + + it 'combines date range' do + result = search('first_after:6m first_before:1w', user: user) + expect(result[:topics]).to include(old_topic) + expect(result[:topics]).not_to include(recent_topic) + end + end + + describe 'state-based queries' do + let!(:starred_topic) do + topic = create(:topic, title: 'Starred Topic') + create(:message, topic: topic) + create(:topic_star, user: user, topic: topic) + topic.update_denormalized_counts! + topic + end + + let!(:unstarred_topic) do + topic = create(:topic, title: 'Unstarred Topic') + create(:message, topic: topic) + topic.update_denormalized_counts! + topic + end + + it 'finds starred topics' do + result = search('starred:me', user: user) + expect(result[:topics]).to include(starred_topic) + expect(result[:topics]).not_to include(unstarred_topic) + end + + it 'finds topics with notes' do + topic_with_note = create(:topic, title: 'Topic with Note') + create(:message, topic: topic_with_note) + create(:note, topic: topic_with_note, author: user) + topic_with_note.update_denormalized_counts! + + result = search('notes:me', user: user) + expect(result[:topics]).to include(topic_with_note) + expect(result[:topics]).not_to include(starred_topic) + end + end + + describe 'count-based queries' do + let!(:active_topic) do + topic = create(:topic, title: 'Active Discussion', message_count: 25, participant_count: 8) + topic + end + + let!(:quiet_topic) do + topic = create(:topic, title: 'Quiet Topic', message_count: 2, participant_count: 1) + topic + end + + it 'finds topics with many messages' do + result = search('messages:>10', user: user) + expect(result[:topics]).to include(active_topic) + expect(result[:topics]).not_to include(quiet_topic) + end + + it 'finds topics with few participants' do + result = search('participants:<3', user: user) + expect(result[:topics]).to include(quiet_topic) + expect(result[:topics]).not_to include(active_topic) + end + + it 'combines count with text search' do + result = search('active messages:>10', user: user) + expect(result[:topics]).to include(active_topic) + end + end + + describe 'presence-based queries' do + let!(:topic_with_patch) do + topic = create(:topic, title: 'Patch Topic') + msg = create(:message, topic: topic) + create(:attachment, message: msg, file_name: 'feature.patch') + topic.update_denormalized_counts! + topic + end + + let!(:topic_without_patch) do + topic = create(:topic, title: 'Discussion Topic') + create(:message, topic: topic) + topic.update_denormalized_counts! + topic + end + + it 'finds topics with patches' do + result = search('has:patch', user: user) + expect(result[:topics]).to include(topic_with_patch) + expect(result[:topics]).not_to include(topic_without_patch) + end + + it 'finds topics without patches' do + result = search('-has:patch', user: user) + expect(result[:topics]).to include(topic_without_patch) + expect(result[:topics]).not_to include(topic_with_patch) + end + end + + describe 'error handling' do + it 'returns warnings for empty selectors' do + result = search('from: title:test', user: user) + expect(result[:warnings]).to include(/empty value/i) + end + + it 'returns warnings for invalid dates' do + result = search('first_after:notadate', user: user) + expect(result[:warnings]).to include(/invalid date/i) + end + + it 'returns warnings for invalid counts' do + result = search('messages:abc', user: user) + expect(result[:warnings]).to include(/invalid count/i) + end + end +end diff --git a/spec/services/search/query_builder_spec.rb b/spec/services/search/query_builder_spec.rb new file mode 100644 index 0000000..15010ab --- /dev/null +++ b/spec/services/search/query_builder_spec.rb @@ -0,0 +1,847 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Search::QueryBuilder, type: :service do + let(:person) { create(:person) } + let(:user) { create(:user, person: person) } + let(:parser) { Search::QueryParser.new } + + def build_query(query_string) + ast = parser.parse(query_string) + validated = Search::QueryValidator.new(ast).validate + described_class.new(ast: validated.ast, user: user).build + end + + describe '#build' do + describe 'text search' do + let!(:topic_with_title) { create(:topic, title: 'PostgreSQL Performance Tuning') } + let!(:topic_other) { create(:topic, title: 'Unrelated Topic') } + + before do + create(:message, topic: topic_with_title, body: 'Some content') + create(:message, topic: topic_other, body: 'Different content') + topic_with_title.update_denormalized_counts! + topic_other.update_denormalized_counts! + end + + it 'searches in title' do + result = build_query('postgresql') + expect(result.relation).to include(topic_with_title) + expect(result.relation).not_to include(topic_other) + end + + it 'searches in body' do + topic_body = create(:topic, title: 'Different') + create(:message, topic: topic_body, body: 'postgresql optimization tips') + topic_body.update_denormalized_counts! + + result = build_query('optimization') + expect(result.relation).to include(topic_body) + end + + context 'phrase matching with quotes' do + let!(:topic_phrase) { create(:topic, title: 'Shared Buffers Configuration') } + let!(:topic_words_apart) { create(:topic, title: 'Shared memory and ring Buffers') } + + before do + create(:message, topic: topic_phrase, body: 'content about shared buffers') + create(:message, topic: topic_words_apart, body: 'shared data with multiple buffers') + topic_phrase.update_denormalized_counts! + topic_words_apart.update_denormalized_counts! + end + + it 'matches phrase exactly when quoted' do + result = build_query('"shared buffers"') + topic_ids = result.relation.pluck(:id) + # Only topic_phrase has "shared buffers" as adjacent words + expect(topic_ids).to include(topic_phrase.id) + expect(topic_ids).not_to include(topic_words_apart.id) + end + + it 'matches words anywhere when unquoted' do + result = build_query('shared buffers') + topic_ids = result.relation.pluck(:id) + # Both topics contain both words (though not adjacent in topic_words_apart) + expect(topic_ids).to include(topic_phrase.id) + expect(topic_ids).to include(topic_words_apart.id) + end + + it 'supports phrase matching in title: selector' do + result = build_query('title:"shared buffers"') + topic_ids = result.relation.pluck(:id) + expect(topic_ids).to include(topic_phrase.id) + expect(topic_ids).not_to include(topic_words_apart.id) + end + + it 'supports phrase matching in body: selector' do + topic_body_phrase = create(:topic, title: 'Other Topic') + create(:message, topic: topic_body_phrase, body: 'discussing shared buffers settings') + topic_body_phrase.update_denormalized_counts! + + topic_body_apart = create(:topic, title: 'Another Topic') + create(:message, topic: topic_body_apart, body: 'shared settings for buffers') + topic_body_apart.update_denormalized_counts! + + result = build_query('body:"shared buffers"') + topic_ids = result.relation.pluck(:id) + expect(topic_ids).to include(topic_body_phrase.id) + expect(topic_ids).not_to include(topic_body_apart.id) + end + end + end + + describe 'from: selector' do + let(:john_person) { create(:person) } + let(:john_alias) { create(:alias, name: 'John Doe', email: 'john@example.com', person: john_person) } + let!(:topic_from_john) { create(:topic, title: 'Topic from John') } + + before do + create(:message, topic: topic_from_john, sender: john_alias, sender_person_id: john_person.id) + topic_from_john.update_denormalized_counts! + end + + it 'filters by author name' do + result = build_query('from:john') + expect(result.relation).to include(topic_from_john) + end + + it 'filters by author email' do + result = build_query('from:john@example.com') + expect(result.relation).to include(topic_from_john) + end + + it 'filters by from:me' do + my_alias = create(:alias, person: person) + my_topic = create(:topic, title: 'My Topic') + create(:message, topic: my_topic, sender: my_alias, sender_person_id: person.id) + my_topic.update_denormalized_counts! + + result = build_query('from:me') + expect(result.relation).to include(my_topic) + expect(result.relation).not_to include(topic_from_john) + end + end + + describe 'starter: selector' do + let!(:topic) { create(:topic, creator_person_id: person.id) } + let!(:other_topic) { create(:topic) } + + it 'filters by topic creator' do + result = build_query('starter:me') + expect(result.relation).to include(topic) + expect(result.relation).not_to include(other_topic) + end + end + + describe 'date selectors' do + let!(:recent_topic) { create(:topic, created_at: 2.days.ago) } + let!(:old_topic) { create(:topic, created_at: 2.months.ago) } + + before do + create(:message, topic: recent_topic, created_at: 2.days.ago) + create(:message, topic: old_topic, created_at: 2.months.ago) + recent_topic.update_denormalized_counts! + old_topic.update_denormalized_counts! + end + + it 'filters by first_after' do + result = build_query('first_after:1w') + expect(result.relation).to include(recent_topic) + expect(result.relation).not_to include(old_topic) + end + + it 'filters by first_before' do + result = build_query('first_before:1w') + expect(result.relation).to include(old_topic) + expect(result.relation).not_to include(recent_topic) + end + + it 'filters by last_after using last_message_at' do + result = build_query('last_after:1w') + expect(result.relation).to include(recent_topic) + expect(result.relation).not_to include(old_topic) + end + end + + describe 'count selectors' do + let!(:large_topic) { create(:topic, message_count: 15, participant_count: 5) } + let!(:small_topic) { create(:topic, message_count: 2, participant_count: 1) } + + it 'filters by messages:>N' do + result = build_query('messages:>10') + expect(result.relation).to include(large_topic) + expect(result.relation).not_to include(small_topic) + end + + it 'filters by messages:N' do + result = build_query('participants:>3') + expect(result.relation).to include(large_topic) + expect(result.relation).not_to include(small_topic) + end + + it 'filters by exact count' do + result = build_query('messages:2') + expect(result.relation).to include(small_topic) + expect(result.relation).not_to include(large_topic) + end + end + + describe 'has: selector' do + describe 'has:attachment' do + let!(:topic_with_attachment) { create(:topic) } + let!(:topic_without_attachment) { create(:topic) } + + before do + msg_with = create(:message, topic: topic_with_attachment) + create(:attachment, message: msg_with) + create(:message, topic: topic_without_attachment) + end + + it 'filters topics with attachments' do + result = build_query('has:attachment') + expect(result.relation).to include(topic_with_attachment) + expect(result.relation).not_to include(topic_without_attachment) + end + end + + describe 'has:patch' do + let!(:topic_with_patch) { create(:topic) } + let!(:topic_without_patch) { create(:topic) } + + before do + msg = create(:message, topic: topic_with_patch) + create(:attachment, message: msg, file_name: 'feature.patch') + msg2 = create(:message, topic: topic_without_patch) + create(:attachment, message: msg2, file_name: 'document.pdf') + end + + it 'filters topics with patch files' do + result = build_query('has:patch') + expect(result.relation).to include(topic_with_patch) + expect(result.relation).not_to include(topic_without_patch) + end + end + + describe 'has:contributor' do + let!(:topic_with_contributor) { create(:topic, contributor_participant_count: 2) } + let!(:topic_without_contributor) { create(:topic, contributor_participant_count: 0) } + + it 'filters topics with contributor participation' do + result = build_query('has:contributor') + expect(result.relation).to include(topic_with_contributor) + expect(result.relation).not_to include(topic_without_contributor) + end + end + end + + describe 'state selectors' do + describe 'starred:me' do + let!(:starred_topic) { create(:topic) } + let!(:unstarred_topic) { create(:topic) } + + before do + create(:topic_star, user: user, topic: starred_topic) + end + + it 'filters starred topics' do + result = build_query('starred:me') + expect(result.relation).to include(starred_topic) + expect(result.relation).not_to include(unstarred_topic) + end + end + + describe 'notes:me' do + let!(:topic_with_note) { create(:topic) } + let!(:topic_without_note) { create(:topic) } + + before do + create(:note, topic: topic_with_note, author: user) + end + + it 'filters topics with notes' do + result = build_query('notes:me') + expect(result.relation).to include(topic_with_note) + expect(result.relation).not_to include(topic_without_note) + end + end + + describe 'tag: selector' do + let!(:topic_tagged) { create(:topic) } + let!(:topic_untagged) { create(:topic) } + let!(:note_with_tag) { create(:note, topic: topic_tagged, author: user) } + + before do + create(:note_tag, note: note_with_tag, tag: 'important') + end + + it 'filters topics with specific tag from current user' do + result = build_query('tag:important[from:me]') + expect(result.relation).to include(topic_tagged) + expect(result.relation).not_to include(topic_untagged) + end + + it 'filters topics with any tag from current user' do + result = build_query('tag:[from:me]') + expect(result.relation).to include(topic_tagged) + expect(result.relation).not_to include(topic_untagged) + end + + it 'filters topics with specific tag from any accessible source' do + result = build_query('tag:important') + expect(result.relation).to include(topic_tagged) + expect(result.relation).not_to include(topic_untagged) + end + + it 'excludes topics with tag when negated' do + result = build_query('-tag:important') + expect(result.relation).not_to include(topic_tagged) + expect(result.relation).to include(topic_untagged) + end + + it 'is case-insensitive for tag names' do + result = build_query('tag:IMPORTANT[from:me]') + expect(result.relation).to include(topic_tagged) + end + + context 'with team tags' do + let(:team) { create(:team, name: 'reviewers', visibility: :visible) } + let(:teammate) { create(:user, person: create(:person)) } + let!(:topic_tagged_by_teammate) { create(:topic) } + let!(:topic_not_tagged_by_team) { create(:topic) } + let!(:teammate_note) { create(:note, topic: topic_tagged_by_teammate, author: teammate) } + + before do + create(:team_member, team: team, user: user) + create(:team_member, team: team, user: teammate) + create(:note_mention, note: teammate_note, mentionable: team) + create(:note_tag, note: teammate_note, tag: 'review-needed') + end + + it 'filters topics with tag from team members' do + result = build_query('tag:review-needed[from:reviewers]') + expect(result.relation).to include(topic_tagged_by_teammate) + # topic_tagged has 'important' tag, not 'review-needed' + expect(result.relation).not_to include(topic_tagged) + end + + it 'filters topics with any tag from team members' do + result = build_query('tag:[from:reviewers]') + # Both topics with tags from team members are included + # (user is a team member, so topic_tagged is included too) + expect(result.relation).to include(topic_tagged_by_teammate) + expect(result.relation).to include(topic_tagged) + # Topic with no team member tags should be excluded + expect(result.relation).not_to include(topic_not_tagged_by_team) + end + end + + context 'when not signed in' do + it 'returns empty result with warning' do + ast = parser.parse('tag:important') + validated = Search::QueryValidator.new(ast).validate + result = described_class.new(ast: validated.ast, user: nil).build + expect(result.relation.count).to eq(0) + end + end + end + end + + describe 'negation' do + let!(:topic) { create(:topic, title: 'PostgreSQL Feature') } + let!(:other_topic) { create(:topic, title: 'Unrelated Discussion') } + + before do + create(:message, topic: topic, body: 'PostgreSQL content') + create(:message, topic: other_topic, body: 'Different content') + topic.update_denormalized_counts! + other_topic.update_denormalized_counts! + end + + it 'negates text search' do + result = build_query('-postgresql') + topic_ids = result.relation.pluck(:id) + expect(topic_ids).not_to include(topic.id) + # other_topic should be included (doesn't match 'postgresql') + expect(topic_ids).to include(other_topic.id) + end + + it 'negates has: selector' do + topic.update!(contributor_participant_count: 1) + other_topic.update!(contributor_participant_count: 0) + result = build_query('-has:contributor') + topic_ids = result.relation.pluck(:id) + expect(topic_ids).not_to include(topic.id) + expect(topic_ids).to include(other_topic.id) + end + + it 'negates grouped OR expression' do + # topic matches 'postgresql' (title: 'PostgreSQL Feature') + # other_topic title is 'Unrelated Discussion' - matches 'unrelated'! + # Create a topic that matches neither + clean_topic = create(:topic, title: 'Clean Topic') + create(:message, topic: clean_topic, body: 'nothing special here') + clean_topic.update_denormalized_counts! + + result = build_query('-(postgresql OR unrelated)') + topic_ids = result.relation.pluck(:id) + # topic matches 'postgresql' - should be excluded + expect(topic_ids).not_to include(topic.id) + # other_topic matches 'unrelated' - should be excluded + expect(topic_ids).not_to include(other_topic.id) + # clean_topic matches neither - should be included + expect(topic_ids).to include(clean_topic.id) + end + + it 'negates grouped AND expression' do + # Create a topic that matches both terms + topic_both = create(:topic, title: 'PostgreSQL Performance Guide') + create(:message, topic: topic_both, body: 'performance tuning tips') + topic_both.update_denormalized_counts! + + result = build_query('-(postgresql performance)') + topic_ids = result.relation.pluck(:id) + # topic_both matches both 'postgresql' AND 'performance' - should be excluded + expect(topic_ids).not_to include(topic_both.id) + # topic matches 'postgresql' but not 'performance' - should be included + expect(topic_ids).to include(topic.id) + expect(topic_ids).to include(other_topic.id) + end + + it 'negates grouped expression with selectors' do + tom_person = create(:person) + tom_alias = create(:alias, name: 'Tom Lane', email: 'tom@example.com', person: tom_person) + bruce_person = create(:person) + bruce_alias = create(:alias, name: 'Bruce Momjian', email: 'bruce@example.com', person: bruce_person) + + topic_from_tom = create(:topic, title: 'Tom Topic') + create(:message, topic: topic_from_tom, sender: tom_alias, sender_person_id: tom_person.id) + topic_from_tom.update_denormalized_counts! + + topic_from_bruce = create(:topic, title: 'Bruce Topic') + create(:message, topic: topic_from_bruce, sender: bruce_alias, sender_person_id: bruce_person.id) + topic_from_bruce.update_denormalized_counts! + + result = build_query('-(from:tom OR from:bruce)') + topic_ids = result.relation.pluck(:id) + expect(topic_ids).not_to include(topic_from_tom.id) + expect(topic_ids).not_to include(topic_from_bruce.id) + expect(topic_ids).to include(topic.id) + expect(topic_ids).to include(other_topic.id) + end + end + + describe 'boolean operators' do + let!(:topic1) { create(:topic, title: 'PostgreSQL Database Guide') } + let!(:topic2) { create(:topic, title: 'MySQL Administration Tips') } + let!(:topic3) { create(:topic, title: 'Oracle DBA Handbook') } + + before do + [topic1, topic2, topic3].each do |t| + create(:message, topic: t, body: 'Some content') + t.update_denormalized_counts! + end + end + + it 'handles OR' do + result = build_query('postgresql OR mysql') + topic_ids = result.relation.pluck(:id) + expect(topic_ids).to include(topic1.id, topic2.id) + expect(topic_ids).not_to include(topic3.id) + end + + it 'handles implicit AND' do + topic_both = create(:topic, title: 'PostgreSQL and MySQL Comparison Guide') + create(:message, topic: topic_both, body: 'detailed comparison') + topic_both.update_denormalized_counts! + + result = build_query('postgresql comparison') + topic_ids = result.relation.pluck(:id) + expect(topic_ids).to include(topic_both.id) + # topic1 has postgresql but not comparison + expect(topic_ids).not_to include(topic1.id) + end + end + + describe 'with nil AST' do + it 'returns empty relation' do + result = described_class.new(ast: nil, user: user).build + expect(result.relation.count).to eq(0) + expect(result.warnings).to be_empty + end + end + + describe 'dependent conditions' do + describe 'from: with conditions' do + let(:bruce_person) { create(:person) } + let(:bruce_alias) { create(:alias, name: 'Bruce Momjian', email: 'bruce@postgresql.org', person: bruce_person) } + let(:tom_person) { create(:person) } + let(:tom_alias) { create(:alias, name: 'Tom Lane', email: 'tom@postgresql.org', person: tom_person) } + + let!(:topic_bruce_10_msgs) { create(:topic, title: 'Bruce Discussion') } + let!(:topic_bruce_2_msgs) { create(:topic, title: 'Bruce Quick Question') } + let!(:topic_tom_many_msgs) { create(:topic, title: 'Tom Discussion') } + + before do + # Bruce posts 10 messages in first topic + 10.times do + create(:message, topic: topic_bruce_10_msgs, sender: bruce_alias, sender_person_id: bruce_person.id) + end + topic_bruce_10_msgs.update_denormalized_counts! + + # Bruce posts 2 messages in second topic + 2.times do + create(:message, topic: topic_bruce_2_msgs, sender: bruce_alias, sender_person_id: bruce_person.id) + end + topic_bruce_2_msgs.update_denormalized_counts! + + # Tom posts many messages + 15.times do + create(:message, topic: topic_tom_many_msgs, sender: tom_alias, sender_person_id: tom_person.id) + end + topic_tom_many_msgs.update_denormalized_counts! + + # Create/update topic_participants records (use update_all to ensure values are set correctly) + tp1 = TopicParticipant.find_or_create_by!(topic: topic_bruce_10_msgs, person: bruce_person) do |tp| + tp.first_message_at = 2.weeks.ago + tp.last_message_at = 1.week.ago + end + tp1.update!(message_count: 10, first_message_at: 2.weeks.ago, last_message_at: 1.week.ago) + + tp2 = TopicParticipant.find_or_create_by!(topic: topic_bruce_2_msgs, person: bruce_person) do |tp| + tp.first_message_at = 1.day.ago + tp.last_message_at = 1.day.ago + end + tp2.update!(message_count: 2, first_message_at: 1.day.ago, last_message_at: 1.day.ago) + + tp3 = TopicParticipant.find_or_create_by!(topic: topic_tom_many_msgs, person: tom_person) do |tp| + tp.first_message_at = 3.months.ago + tp.last_message_at = 2.months.ago + end + tp3.update!(message_count: 15, first_message_at: 3.months.ago, last_message_at: 2.months.ago) + end + + it 'filters by message count condition' do + result = build_query('from:bruce[messages:>=10]') + expect(result.relation).to include(topic_bruce_10_msgs) + expect(result.relation).not_to include(topic_bruce_2_msgs) + expect(result.relation).not_to include(topic_tom_many_msgs) + end + + it 'filters by message count with less than condition' do + result = build_query('from:bruce[messages:<5]') + expect(result.relation).to include(topic_bruce_2_msgs) + expect(result.relation).not_to include(topic_bruce_10_msgs) + end + + it 'filters by last_before condition' do + result = build_query('from:tom[last_before:1m]') + expect(result.relation).to include(topic_tom_many_msgs) + expect(result.relation).not_to include(topic_bruce_10_msgs) + expect(result.relation).not_to include(topic_bruce_2_msgs) + end + + it 'filters by last_after condition' do + # topic_bruce_10_msgs: last_message_at = 1.week.ago + # topic_bruce_2_msgs: last_message_at = 1.day.ago + # Using 3d threshold: only 1.day.ago matches (is after 3 days ago) + result = build_query('from:bruce[last_after:3d]') + expect(result.relation).to include(topic_bruce_2_msgs) + expect(result.relation).not_to include(topic_bruce_10_msgs) + end + + it 'combines multiple conditions' do + # Both topics have messages:>=2, but only topic_bruce_2_msgs was active within 3 days + result = build_query('from:bruce[messages:>=2, last_after:3d]') + expect(result.relation).to include(topic_bruce_2_msgs) + expect(result.relation).not_to include(topic_bruce_10_msgs) + end + + it 'supports negation with conditions' do + result = build_query('-from:bruce[messages:>=10]') + expect(result.relation).not_to include(topic_bruce_10_msgs) + expect(result.relation).to include(topic_bruce_2_msgs) + expect(result.relation).to include(topic_tom_many_msgs) + end + end + + describe 'from: with body condition' do + let(:bruce_person) { create(:person) } + let(:bruce_alias) { create(:alias, name: 'Bruce Momjian', email: 'bruce@postgresql.org', person: bruce_person) } + let(:tom_person) { create(:person) } + let(:tom_alias) { create(:alias, name: 'Tom Lane', email: 'tom@postgresql.org', person: tom_person) } + + let!(:topic_bruce_patch) { create(:topic, title: 'Bruce Patch Topic') } + let!(:topic_tom_patch) { create(:topic, title: 'Tom Patch Topic') } + + before do + create(:message, topic: topic_bruce_patch, sender: bruce_alias, sender_person_id: bruce_person.id, body: 'Here is my patch for the issue') + create(:message, topic: topic_tom_patch, sender: tom_alias, sender_person_id: tom_person.id, body: 'Here is my patch for the issue') + topic_bruce_patch.update_denormalized_counts! + topic_tom_patch.update_denormalized_counts! + + TopicParticipant.find_or_create_by!(topic: topic_bruce_patch, person: bruce_person) do |tp| + tp.message_count = 1 + tp.first_message_at = 1.day.ago + tp.last_message_at = 1.day.ago + end + TopicParticipant.find_or_create_by!(topic: topic_tom_patch, person: tom_person) do |tp| + tp.message_count = 1 + tp.first_message_at = 1.day.ago + tp.last_message_at = 1.day.ago + end + end + + it 'filters by body content for specific author' do + result = build_query('from:bruce[body:patch]') + expect(result.relation).to include(topic_bruce_patch) + expect(result.relation).not_to include(topic_tom_patch) + end + end + + describe 'has:attachment with conditions' do + let(:bruce_person) { create(:person) } + let(:bruce_alias) { create(:alias, name: 'Bruce Momjian', email: 'bruce@postgresql.org', person: bruce_person) } + let(:tom_person) { create(:person) } + let(:tom_alias) { create(:alias, name: 'Tom Lane', email: 'tom@postgresql.org', person: tom_person) } + + let!(:topic_bruce_attachments) { create(:topic, title: 'Bruce Attachments') } + let!(:topic_tom_attachments) { create(:topic, title: 'Tom Attachments') } + let!(:topic_few_attachments) { create(:topic, title: 'Few Attachments') } + + before do + # Bruce posts 3 attachments + msg1 = create(:message, topic: topic_bruce_attachments, sender: bruce_alias, sender_person_id: bruce_person.id) + create(:attachment, message: msg1, file_name: 'fix1.patch') + create(:attachment, message: msg1, file_name: 'fix2.patch') + msg2 = create(:message, topic: topic_bruce_attachments, sender: bruce_alias, sender_person_id: bruce_person.id) + create(:attachment, message: msg2, file_name: 'fix3.patch') + + # Tom posts attachments + msg3 = create(:message, topic: topic_tom_attachments, sender: tom_alias, sender_person_id: tom_person.id) + create(:attachment, message: msg3, file_name: 'document.pdf') + + # Topic with 1 attachment + msg4 = create(:message, topic: topic_few_attachments, sender: bruce_alias, sender_person_id: bruce_person.id) + create(:attachment, message: msg4, file_name: 'readme.txt') + end + + it 'filters by attachment author' do + result = build_query('has:attachment[from:bruce]') + expect(result.relation).to include(topic_bruce_attachments) + expect(result.relation).to include(topic_few_attachments) + expect(result.relation).not_to include(topic_tom_attachments) + end + + it 'filters by attachment count' do + result = build_query('has:attachment[count:>=3]') + expect(result.relation).to include(topic_bruce_attachments) + expect(result.relation).not_to include(topic_tom_attachments) + expect(result.relation).not_to include(topic_few_attachments) + end + + it 'combines from and count conditions' do + result = build_query('has:attachment[from:bruce,count:>=3]') + expect(result.relation).to include(topic_bruce_attachments) + expect(result.relation).not_to include(topic_few_attachments) + end + + it 'filters by attachment name' do + result = build_query('has:attachment[name:patch]') + expect(result.relation).to include(topic_bruce_attachments) + expect(result.relation).not_to include(topic_tom_attachments) + expect(result.relation).not_to include(topic_few_attachments) + end + end + + describe 'has:patch with conditions' do + let(:bruce_person) { create(:person) } + let(:bruce_alias) { create(:alias, name: 'Bruce Momjian', email: 'bruce@postgresql.org', person: bruce_person) } + let(:tom_person) { create(:person) } + let(:tom_alias) { create(:alias, name: 'Tom Lane', email: 'tom@postgresql.org', person: tom_person) } + + let!(:topic_bruce_patches) { create(:topic, title: 'Bruce Patches') } + let!(:topic_tom_patches) { create(:topic, title: 'Tom Patches') } + + before do + msg1 = create(:message, topic: topic_bruce_patches, sender: bruce_alias, sender_person_id: bruce_person.id) + create(:attachment, message: msg1, file_name: 'fix1.patch') + create(:attachment, message: msg1, file_name: 'fix2.diff') + + msg2 = create(:message, topic: topic_tom_patches, sender: tom_alias, sender_person_id: tom_person.id) + create(:attachment, message: msg2, file_name: 'fix.patch') + end + + it 'filters patches by author' do + result = build_query('has:patch[from:bruce]') + expect(result.relation).to include(topic_bruce_patches) + expect(result.relation).not_to include(topic_tom_patches) + end + + it 'filters patches by count' do + result = build_query('has:patch[count:>=2]') + expect(result.relation).to include(topic_bruce_patches) + expect(result.relation).not_to include(topic_tom_patches) + end + end + + describe 'tag: with conditions' do + let!(:topic_tagged_by_me) { create(:topic) } + let!(:topic_tagged_by_other) { create(:topic) } + let!(:topic_old_tag) { create(:topic) } + let(:other_user) { create(:user, person: create(:person)) } + + before do + note1 = create(:note, topic: topic_tagged_by_me, author: user, created_at: 1.day.ago) + create(:note_tag, note: note1, tag: 'review') + + # Note from other_user, shared with current user via mention + note2 = create(:note, topic: topic_tagged_by_other, author: other_user, created_at: 1.day.ago) + create(:note_tag, note: note2, tag: 'review') + create(:note_mention, note: note2, mentionable: user) + + note3 = create(:note, topic: topic_old_tag, author: user, created_at: 2.months.ago) + create(:note_tag, note: note3, tag: 'review') + end + + it 'filters tags by author using from: condition' do + result = build_query('tag:review[from:me]') + expect(result.relation).to include(topic_tagged_by_me) + expect(result.relation).to include(topic_old_tag) + expect(result.relation).not_to include(topic_tagged_by_other) + end + + it 'filters tags by added_after' do + result = build_query('tag:review[added_after:1w]') + expect(result.relation).to include(topic_tagged_by_me) + expect(result.relation).to include(topic_tagged_by_other) + expect(result.relation).not_to include(topic_old_tag) + end + + it 'filters tags by added_before' do + result = build_query('tag:review[added_before:1w]') + expect(result.relation).to include(topic_old_tag) + expect(result.relation).not_to include(topic_tagged_by_me) + end + + it 'combines from and date conditions' do + result = build_query('tag:review[from:me, added_after:1w]') + expect(result.relation).to include(topic_tagged_by_me) + expect(result.relation).not_to include(topic_old_tag) + expect(result.relation).not_to include(topic_tagged_by_other) + end + + it 'filters any tag with from condition (empty tag name)' do + result = build_query('tag:[from:me]') + expect(result.relation).to include(topic_tagged_by_me) + expect(result.relation).to include(topic_old_tag) + expect(result.relation).not_to include(topic_tagged_by_other) + end + end + + describe 'team aggregation' do + let(:team) { create(:team, name: 'core', visibility: :visible) } + let(:bruce_person) { create(:person) } + let(:bruce_user) { create(:user, person: bruce_person) } + let(:tom_person) { create(:person) } + let(:tom_user) { create(:user, person: tom_person) } + let(:bruce_alias) { create(:alias, name: 'Bruce', person: bruce_person) } + let(:tom_alias) { create(:alias, name: 'Tom', person: tom_person) } + + let!(:topic_team_active) { create(:topic, title: 'Team Active') } + let!(:topic_team_inactive) { create(:topic, title: 'Team Inactive') } + + before do + create(:team_member, team: team, user: bruce_user) + create(:team_member, team: team, user: tom_user) + + # Team members active in first topic + create(:message, topic: topic_team_active, sender: bruce_alias, sender_person_id: bruce_person.id) + create(:message, topic: topic_team_active, sender: tom_alias, sender_person_id: tom_person.id) + + # Create/update topic_participants records + tp1 = TopicParticipant.find_or_create_by!(topic: topic_team_active, person: bruce_person) do |tp| + tp.first_message_at = 1.week.ago + tp.last_message_at = 1.day.ago + end + tp1.update!(message_count: 5, first_message_at: 1.week.ago, last_message_at: 1.day.ago) + + tp2 = TopicParticipant.find_or_create_by!(topic: topic_team_active, person: tom_person) do |tp| + tp.first_message_at = 1.week.ago + tp.last_message_at = 2.days.ago + end + tp2.update!(message_count: 3, first_message_at: 1.week.ago, last_message_at: 2.days.ago) + + # Team members inactive in second topic - need to create messages first for the records to exist + create(:message, topic: topic_team_inactive, sender: bruce_alias, sender_person_id: bruce_person.id) + create(:message, topic: topic_team_inactive, sender: tom_alias, sender_person_id: tom_person.id) + + tp3 = TopicParticipant.find_or_create_by!(topic: topic_team_inactive, person: bruce_person) do |tp| + tp.first_message_at = 3.months.ago + tp.last_message_at = 2.months.ago + end + tp3.update!(message_count: 2, first_message_at: 3.months.ago, last_message_at: 2.months.ago) + + tp4 = TopicParticipant.find_or_create_by!(topic: topic_team_inactive, person: tom_person) do |tp| + tp.first_message_at = 3.months.ago + tp.last_message_at = 2.months.ago + end + tp4.update!(message_count: 1, first_message_at: 3.months.ago, last_message_at: 2.months.ago) + + topic_team_active.update_denormalized_counts! + topic_team_inactive.update_denormalized_counts! + end + + it 'aggregates message count across team members' do + # Combined: 5+3=8 in active, 2+1=3 in inactive + result = build_query('from:core[messages:>=5]') + expect(result.relation).to include(topic_team_active) + expect(result.relation).not_to include(topic_team_inactive) + end + + it 'filters by activity date for all team members' do + result = build_query('from:core[last_before:1m]') + expect(result.relation).to include(topic_team_inactive) + expect(result.relation).not_to include(topic_team_active) + end + end + end + + describe 'FTS sanitization' do + let!(:topic_vacuum) { create(:topic, title: 'Vacuum Performance Improvements') } + let!(:topic_other) { create(:topic, title: 'Unrelated Topic') } + + before do + create(:message, topic: topic_vacuum, body: 'Discussing vacuum improvements') + create(:message, topic: topic_other, body: 'Something else entirely') + topic_vacuum.update_denormalized_counts! + topic_other.update_denormalized_counts! + end + + it 'handles quoted phrases with special characters without errors' do + # phraseto_tsquery handles special characters gracefully + result = build_query('"vacuum & performance"') + expect(result.warnings).to be_empty + end + + it 'handles multiple spaces between search terms' do + result = build_query('vacuum performance') + expect(result.relation).to include(topic_vacuum) + expect(result.relation).not_to include(topic_other) + end + + it 'searches title with FTS stemming' do + # "improvements" should match via stemming (improve -> improv) + result = build_query('title:improve') + expect(result.relation).to include(topic_vacuum) + expect(result.relation).not_to include(topic_other) + end + end + end +end diff --git a/spec/services/search/query_parser_spec.rb b/spec/services/search/query_parser_spec.rb new file mode 100644 index 0000000..353629f --- /dev/null +++ b/spec/services/search/query_parser_spec.rb @@ -0,0 +1,336 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Search::QueryParser, type: :service do + let(:parser) { described_class.new } + + describe '#parse' do + context 'with simple text' do + it 'parses a single word' do + result = parser.parse('postgresql') + expect(result[:type]).to eq(:text) + expect(result[:value]).to eq('postgresql') + expect(result[:negated]).to be false + end + + it 'parses multiple words as implicit AND' do + result = parser.parse('postgresql vacuum') + expect(result[:type]).to eq(:and) + expect(result[:children].size).to eq(2) + expect(result[:children][0][:value]).to eq('postgresql') + expect(result[:children][1][:value]).to eq('vacuum') + end + + it 'parses quoted text' do + result = parser.parse('"query planning"') + expect(result[:type]).to eq(:text) + expect(result[:value]).to eq('query planning') + expect(result[:quoted]).to be true + end + end + + context 'with selectors' do + it 'parses from: selector' do + result = parser.parse('from:john') + expect(result[:type]).to eq(:selector) + expect(result[:key]).to eq(:from) + expect(result[:value]).to eq('john') + expect(result[:negated]).to be false + end + + it 'parses title: selector with quoted value' do + result = parser.parse('title:"query planning"') + expect(result[:type]).to eq(:selector) + expect(result[:key]).to eq(:title) + expect(result[:value]).to eq('query planning') + expect(result[:quoted]).to be true + end + + it 'parses all date selectors' do + %w[first_after first_before messages_after messages_before last_after last_before].each do |selector| + result = parser.parse("#{selector}:2024-01-01") + expect(result[:type]).to eq(:selector) + expect(result[:key]).to eq(selector.to_sym) + end + end + + it 'parses all state selectors' do + %w[unread read reading new starred notes].each do |selector| + result = parser.parse("#{selector}:me") + expect(result[:type]).to eq(:selector) + expect(result[:key]).to eq(selector.to_sym) + expect(result[:value]).to eq('me') + end + end + + it 'parses count selectors' do + %w[messages participants contributors].each do |selector| + result = parser.parse("#{selector}:>10") + expect(result[:type]).to eq(:selector) + expect(result[:key]).to eq(selector.to_sym) + expect(result[:value]).to eq('>10') + end + end + + it 'parses has: selector' do + result = parser.parse('has:attachment') + expect(result[:type]).to eq(:selector) + expect(result[:key]).to eq(:has) + expect(result[:value]).to eq('attachment') + end + + it 'parses tag: selector' do + result = parser.parse('tag:review') + expect(result[:type]).to eq(:selector) + expect(result[:key]).to eq(:tag) + expect(result[:value]).to eq('review') + end + + it 'parses tag: selector with from: condition' do + result = parser.parse('tag:review[from:me]') + expect(result[:type]).to eq(:selector) + expect(result[:key]).to eq(:tag) + expect(result[:value]).to eq('review') + expect(result[:conditions].size).to eq(1) + expect(result[:conditions][0][:key]).to eq(:from) + expect(result[:conditions][0][:value]).to eq('me') + end + + it 'parses tag: selector with empty value and from: condition' do + result = parser.parse('tag:[from:me]') + expect(result[:type]).to eq(:selector) + expect(result[:key]).to eq(:tag) + expect(result[:value]).to eq('') + expect(result[:conditions].size).to eq(1) + end + end + + context 'with negation' do + it 'parses negated text' do + result = parser.parse('-spam') + expect(result[:type]).to eq(:text) + expect(result[:negated]).to be true + expect(result[:value]).to eq('spam') + end + + it 'parses negated selector' do + result = parser.parse('-from:john') + expect(result[:type]).to eq(:selector) + expect(result[:negated]).to be true + expect(result[:key]).to eq(:from) + expect(result[:value]).to eq('john') + end + + it 'parses negated has: selector' do + result = parser.parse('-has:contributor') + expect(result[:type]).to eq(:selector) + expect(result[:negated]).to be true + expect(result[:key]).to eq(:has) + expect(result[:value]).to eq('contributor') + end + end + + context 'with boolean operators' do + it 'parses explicit OR' do + result = parser.parse('from:john OR from:jane') + expect(result[:type]).to eq(:or) + expect(result[:children].size).to eq(2) + expect(result[:children][0][:value]).to eq('john') + expect(result[:children][1][:value]).to eq('jane') + end + + it 'parses case-insensitive OR' do + result = parser.parse('from:john or from:jane') + expect(result[:type]).to eq(:or) + end + + it 'parses mixed-case OR' do + result = parser.parse('from:john Or from:jane') + expect(result[:type]).to eq(:or) + end + + it 'parses mixed-case AND' do + result = parser.parse('from:john And unread:me') + expect(result[:type]).to eq(:and) + expect(result[:children].size).to eq(2) + end + + it 'parses explicit AND' do + result = parser.parse('from:john AND unread:me') + expect(result[:type]).to eq(:and) + expect(result[:children].size).to eq(2) + end + + it 'handles operator precedence (AND binds tighter than OR)' do + result = parser.parse('from:john unread:me OR from:jane') + expect(result[:type]).to eq(:or) + # First child should be the AND of john and unread:me + expect(result[:children][0][:type]).to eq(:and) + # Second child should be jane + expect(result[:children][1][:value]).to eq('jane') + end + end + + context 'with parentheses' do + it 'parses grouped expression' do + result = parser.parse('(from:john OR from:jane) unread:me') + expect(result[:type]).to eq(:and) + expect(result[:children][0][:type]).to eq(:or) + expect(result[:children][1][:key]).to eq(:unread) + end + + it 'parses negated grouped expression' do + result = parser.parse('-(from:john OR from:jane)') + expect(result[:type]).to eq(:or) + expect(result[:negated]).to be true + end + end + + context 'with complex queries' do + it 'parses complex query with multiple selectors' do + result = parser.parse('from:john title:"postgresql" unread:me') + expect(result[:type]).to eq(:and) + expect(result[:children].size).to eq(3) + end + + it 'parses query with mixed text and selectors' do + result = parser.parse('postgresql from:john vacuum') + expect(result[:type]).to eq(:and) + expect(result[:children].size).to eq(3) + end + end + + context 'with dependent conditions (bracket notation)' do + it 'parses from: selector with single condition' do + result = parser.parse('from:bruce[messages:>=10]') + expect(result[:type]).to eq(:selector) + expect(result[:key]).to eq(:from) + expect(result[:value]).to eq('bruce') + expect(result[:conditions]).to be_an(Array) + expect(result[:conditions].size).to eq(1) + expect(result[:conditions][0][:key]).to eq(:messages) + expect(result[:conditions][0][:value]).to eq('>=10') + end + + it 'parses from: selector with multiple conditions' do + result = parser.parse('from:bruce[messages:>=10, last_before:1m]') + expect(result[:type]).to eq(:selector) + expect(result[:key]).to eq(:from) + expect(result[:conditions].size).to eq(2) + expect(result[:conditions][0][:key]).to eq(:messages) + expect(result[:conditions][0][:value]).to eq('>=10') + expect(result[:conditions][1][:key]).to eq(:last_before) + expect(result[:conditions][1][:value]).to eq('1m') + end + + it 'parses from: selector with body condition' do + result = parser.parse('from:bruce[body:"patch"]') + expect(result[:conditions].size).to eq(1) + expect(result[:conditions][0][:key]).to eq(:body) + expect(result[:conditions][0][:value]).to eq('patch') + expect(result[:conditions][0][:quoted]).to be true + end + + it 'parses has:attachment with conditions' do + result = parser.parse('has:attachment[from:bruce,count:>=3]') + expect(result[:type]).to eq(:selector) + expect(result[:key]).to eq(:has) + expect(result[:value]).to eq('attachment') + expect(result[:conditions].size).to eq(2) + expect(result[:conditions][0][:key]).to eq(:from) + expect(result[:conditions][0][:value]).to eq('bruce') + expect(result[:conditions][1][:key]).to eq(:count) + expect(result[:conditions][1][:value]).to eq('>=3') + end + + it 'parses tag: selector with from condition' do + result = parser.parse('tag:important[from:me]') + expect(result[:type]).to eq(:selector) + expect(result[:key]).to eq(:tag) + expect(result[:value]).to eq('important') + expect(result[:conditions].size).to eq(1) + expect(result[:conditions][0][:key]).to eq(:from) + expect(result[:conditions][0][:value]).to eq('me') + end + + it 'parses tag: selector with empty value and condition' do + result = parser.parse('tag:[from:teamname]') + expect(result[:type]).to eq(:selector) + expect(result[:key]).to eq(:tag) + expect(result[:value]).to eq('') + expect(result[:conditions].size).to eq(1) + expect(result[:conditions][0][:key]).to eq(:from) + end + + it 'parses negated selector with conditions' do + result = parser.parse('-from:bruce[messages:>=10]') + expect(result[:type]).to eq(:selector) + expect(result[:negated]).to be true + expect(result[:key]).to eq(:from) + expect(result[:value]).to eq('bruce') + expect(result[:conditions].size).to eq(1) + end + + it 'parses selector without conditions' do + result = parser.parse('from:bruce') + expect(result[:conditions]).to be_nil + end + + it 'handles whitespace in condition list' do + result = parser.parse('from:bruce[ messages:>=10 , last_before:1m ]') + expect(result[:conditions].size).to eq(2) + expect(result[:conditions][0][:key]).to eq(:messages) + expect(result[:conditions][1][:key]).to eq(:last_before) + end + + it 'parses has:patch with conditions' do + result = parser.parse('has:patch[from:tom,count:>=2]') + expect(result[:key]).to eq(:has) + expect(result[:value]).to eq('patch') + expect(result[:conditions].size).to eq(2) + end + + it 'parses tag with added_before condition' do + result = parser.parse('tag:review[added_before:1w]') + expect(result[:conditions].size).to eq(1) + expect(result[:conditions][0][:key]).to eq(:added_before) + expect(result[:conditions][0][:value]).to eq('1w') + end + end + + context 'with edge cases' do + it 'returns nil for blank query' do + expect(parser.parse('')).to be_nil + expect(parser.parse(' ')).to be_nil + expect(parser.parse(nil)).to be_nil + end + + it 'handles selector with empty value' do + result = parser.parse('from:') + expect(result[:type]).to eq(:selector) + expect(result[:key]).to eq(:from) + expect(result[:value]).to eq('') + end + + it 'handles emails in from selector' do + result = parser.parse('from:john@example.com') + expect(result[:type]).to eq(:selector) + expect(result[:value]).to eq('john@example.com') + end + end + end + + describe '#valid?' do + it 'returns true for valid queries' do + expect(parser.valid?('from:john')).to be true + expect(parser.valid?('postgresql vacuum')).to be true + expect(parser.valid?('(from:john OR from:jane)')).to be true + end + + it 'returns false for invalid syntax' do + expect(parser.valid?('((broken')).to be false + # Note: 'from:john OR OR' is valid - the second OR is parsed as plain text + end + end +end diff --git a/spec/services/search/query_validator_spec.rb b/spec/services/search/query_validator_spec.rb new file mode 100644 index 0000000..eb8a46d --- /dev/null +++ b/spec/services/search/query_validator_spec.rb @@ -0,0 +1,252 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Search::QueryValidator, type: :service do + let(:parser) { Search::QueryParser.new } + + describe '#validate' do + context 'with valid selectors' do + it 'passes valid date selectors' do + ast = parser.parse('first_after:2024-01-01') + result = described_class.new(ast).validate + expect(result.warnings).to be_empty + expect(result.ast).to eq(ast) + end + + it 'passes valid count selectors' do + ast = parser.parse('messages:>10') + result = described_class.new(ast).validate + expect(result.warnings).to be_empty + expect(result.ast).to eq(ast) + end + + it 'passes valid has: selectors' do + ast = parser.parse('has:attachment') + result = described_class.new(ast).validate + expect(result.warnings).to be_empty + end + end + + context 'with empty values' do + it 'warns and removes selector with empty value' do + ast = parser.parse('from:') + result = described_class.new(ast).validate + expect(result.warnings).to include(/empty value/i) + expect(result.ast).to be_nil + end + + it 'keeps other valid selectors when one is empty' do + ast = parser.parse('from: title:postgresql') + result = described_class.new(ast).validate + expect(result.warnings.size).to eq(1) + expect(result.ast[:key]).to eq(:title) + end + end + + context 'with invalid date values' do + it 'warns and removes selector with invalid date' do + ast = parser.parse('first_after:notadate') + result = described_class.new(ast).validate + expect(result.warnings).to include(/invalid date/i) + expect(result.ast).to be_nil + end + end + + context 'with invalid count values' do + it 'warns and removes selector with invalid count' do + ast = parser.parse('messages:abc') + result = described_class.new(ast).validate + expect(result.warnings).to include(/invalid count/i) + expect(result.ast).to be_nil + end + + it 'warns on negative count' do + ast = parser.parse('messages:-5') + result = described_class.new(ast).validate + expect(result.warnings).to include(/invalid count/i) + end + + it 'accepts valid count operators' do + ['messages:10', 'messages:>10', 'messages:<10', 'messages:>=10', 'messages:<=10'].each do |query| + ast = parser.parse(query) + result = described_class.new(ast).validate + expect(result.warnings).to be_empty + end + end + end + + context 'with invalid has: values' do + it 'warns and removes selector with unknown has: value' do + ast = parser.parse('has:unknown') + result = described_class.new(ast).validate + expect(result.warnings).to include(/unknown has:/i) + expect(result.ast).to be_nil + end + end + + context 'with tag: selector' do + it 'passes valid tag name' do + ast = parser.parse('tag:review') + result = described_class.new(ast).validate + expect(result.warnings).to be_empty + expect(result.ast[:key]).to eq(:tag) + end + + it 'passes tag name with from: condition' do + ast = parser.parse('tag:important[from:me]') + result = described_class.new(ast).validate + expect(result.warnings).to be_empty + expect(result.ast[:conditions].size).to eq(1) + end + + it 'passes tag name with from: team condition' do + ast = parser.parse('tag:priority[from:reviewers]') + result = described_class.new(ast).validate + expect(result.warnings).to be_empty + end + + it 'passes empty tag name with from: condition' do + ast = parser.parse('tag:[from:me]') + result = described_class.new(ast).validate + expect(result.warnings).to be_empty + end + + it 'passes tags with dots and dashes' do + ast = parser.parse('tag:needs-review.v2') + result = described_class.new(ast).validate + expect(result.warnings).to be_empty + end + + it 'passes tag with added_after condition' do + ast = parser.parse('tag:review[added_after:1w]') + result = described_class.new(ast).validate + expect(result.warnings).to be_empty + end + + it 'passes tag with multiple conditions' do + ast = parser.parse('tag:review[from:me, added_before:1m]') + result = described_class.new(ast).validate + expect(result.warnings).to be_empty + expect(result.ast[:conditions].size).to eq(2) + end + + it 'warns on invalid tag format starting with special char' do + ast = parser.parse('tag:-invalid') + result = described_class.new(ast).validate + expect(result.warnings).to include(/invalid tag name/i) + expect(result.ast).to be_nil + end + + it 'warns on invalid condition for tag selector' do + ast = parser.parse('tag:review[messages:>=10]') + result = described_class.new(ast).validate + expect(result.warnings).to include(/not valid for 'tag:'/i) + end + end + + context 'with compound expressions' do + it 'validates children of AND expressions' do + ast = parser.parse('from:john first_after:invalid') + result = described_class.new(ast).validate + expect(result.warnings.size).to eq(1) + expect(result.ast[:key]).to eq(:from) + end + + it 'validates children of OR expressions' do + ast = parser.parse('first_after:invalid OR from:john') + result = described_class.new(ast).validate + expect(result.warnings.size).to eq(1) + expect(result.ast[:key]).to eq(:from) + end + + it 'returns nil when all children are invalid' do + ast = parser.parse('first_after:invalid messages:abc') + result = described_class.new(ast).validate + expect(result.warnings.size).to eq(2) + expect(result.ast).to be_nil + end + end + + context 'with nil AST' do + it 'returns empty result' do + result = described_class.new(nil).validate + expect(result.ast).to be_nil + expect(result.warnings).to be_empty + end + end + + context 'with selector-like typos in text' do + it 'warns about typo similar to last_before' do + ast = parser.parse('lasxt_before:1y') + result = described_class.new(ast).validate + expect(result.warnings).to include(/looks like a selector.*last_before/i) + # The text node is still kept for searching + expect(result.ast[:type]).to eq(:text) + end + + it 'warns about typo similar to first_after' do + ast = parser.parse('fist_after:2024') + result = described_class.new(ast).validate + expect(result.warnings).to include(/looks like a selector.*first_after/i) + end + + it 'warns about typo similar to from' do + ast = parser.parse('frm:john') + result = described_class.new(ast).validate + expect(result.warnings).to include(/looks like a selector.*from/i) + end + + it 'warns about unknown selector containing common patterns' do + ast = parser.parse('readby:me') + result = described_class.new(ast).validate + # readby is close to 'read', so it gets the "Did you mean" suggestion + expect(result.warnings).to include(/looks like a selector.*read/i) + end + + it 'warns generically about selector-like patterns without close matches' do + # Use a word that contains a pattern like 'content' but is far from all selectors + ast = parser.parse('mycontent:test') + result = described_class.new(ast).validate + expect(result.warnings).to include(/not a recognized selector/i) + end + + it 'does not warn about regular text with colons like URLs' do + ast = parser.parse('https://example.com') + result = described_class.new(ast).validate + expect(result.warnings).to be_empty + end + + it 'does not warn about quoted text that looks like a selector typo' do + ast = parser.parse('"activxe_before:1y"') + result = described_class.new(ast).validate + expect(result.warnings).to be_empty + end + + it 'does not warn about quoted text in complex queries' do + ast = parser.parse('starter:me OR (from:john "activxe_before:1y")') + result = described_class.new(ast).validate + expect(result.warnings).to be_empty + end + + it 'does not warn about text without colons' do + ast = parser.parse('postgresql') + result = described_class.new(ast).validate + expect(result.warnings).to be_empty + end + + it 'warns about misspelled selector in complex query' do + ast = parser.parse('starter:me OR (from:john lasxt_before:1y)') + result = described_class.new(ast).validate + expect(result.warnings).to include(/lasxt_before.*looks like a selector/i) + end + + it 'suggests multiple similar selectors when applicable' do + ast = parser.parse('firs:2024') + result = described_class.new(ast).validate + # Should suggest first_after and/or first_before + expect(result.warnings.first).to include('first_after').or include('first_before') + end + end + end +end diff --git a/spec/services/search/value_resolver_spec.rb b/spec/services/search/value_resolver_spec.rb new file mode 100644 index 0000000..7e2ee72 --- /dev/null +++ b/spec/services/search/value_resolver_spec.rb @@ -0,0 +1,195 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Search::ValueResolver, type: :service do + let(:person) { create(:person) } + let(:user) { create(:user, person: person) } + let(:resolver) { described_class.new(user: user) } + + describe '#resolve_author' do + context 'with me value' do + it 'resolves to current user person_id' do + result = resolver.resolve_author('me') + expect(result.type).to eq(:persons) + expect(result.person_ids).to eq([person.id]) + expect(result.warnings).to be_empty + end + + it 'returns empty with warning when not signed in' do + resolver_no_user = described_class.new(user: nil) + result = resolver_no_user.resolve_author('me') + expect(result.type).to eq(:empty) + expect(result.warnings).to include(/signed in/i) + end + end + + context 'with contributor type' do + let!(:contributor_person) { create(:person) } + let!(:contributor_membership) { ContributorMembership.create!(person: contributor_person, contributor_type: 'committer') } + + it 'resolves contributor to all contributor person_ids' do + result = resolver.resolve_author('contributor') + expect(result.type).to eq(:persons) + expect(result.person_ids).to include(contributor_person.id) + end + + it 'resolves specific contributor type' do + result = resolver.resolve_author('committer') + expect(result.type).to eq(:persons) + expect(result.person_ids).to include(contributor_person.id) + end + end + + context 'with team name' do + let(:team) { create(:team, name: 'postgresql-core', visibility: :visible) } + let(:team_member_person) { create(:person) } + let(:team_member_user) { create(:user, person: team_member_person) } + + before do + create(:team_member, team: team, user: team_member_user) + end + + it 'resolves team name to team member person_ids' do + result = resolver.resolve_author('postgresql-core') + expect(result.type).to eq(:persons) + expect(result.person_ids).to include(team_member_person.id) + end + + it 'does not resolve inaccessible private team' do + private_team = create(:team, name: 'secret-team', visibility: :private) + create(:team_member, team: private_team, user: team_member_user) + + result = resolver.resolve_author('secret-team') + # Since user is not a member, it should fall back to name/email search + expect(result.type).to eq(:empty) + end + end + + context 'with email value' do + let(:alias_record) { create(:alias, email: 'john@example.com', name: 'John Doe', person: create(:person)) } + + before { alias_record } + + it 'searches email only when value contains @' do + result = resolver.resolve_author('john@example.com') + expect(result.type).to eq(:persons) + expect(result.person_ids).to include(alias_record.person_id) + end + + it 'uses exact match when quoted' do + result = resolver.resolve_author('john@example.com', quoted: true) + expect(result.type).to eq(:persons) + end + end + + context 'with name value' do + let(:alias_record) { create(:alias, email: 'different@example.com', name: 'John Doe', person: create(:person)) } + + before { alias_record } + + it 'searches both name and email when no @' do + result = resolver.resolve_author('john') + expect(result.type).to eq(:persons) + expect(result.person_ids).to include(alias_record.person_id) + end + + it 'uses exact match when quoted' do + result = resolver.resolve_author('John Doe', quoted: true) + expect(result.type).to eq(:persons) + expect(result.person_ids).to include(alias_record.person_id) + end + end + end + + describe '#resolve_state_subject' do + context 'with me value' do + it 'resolves to current user_id' do + result = resolver.resolve_state_subject('me') + expect(result.type).to eq(:users) + expect(result.user_ids).to eq([user.id]) + end + + it 'returns empty with warning when not signed in' do + resolver_no_user = described_class.new(user: nil) + result = resolver_no_user.resolve_state_subject('me') + expect(result.type).to eq(:empty) + expect(result.warnings).to include(/signed in/i) + end + end + + context 'with team name' do + let(:team) { create(:team, name: 'my-team', visibility: :visible) } + let(:teammate) { create(:user, person: create(:person)) } + + before do + create(:team_member, team: team, user: user) + create(:team_member, team: team, user: teammate) + end + + it 'resolves to team member user_ids when user is member' do + result = resolver.resolve_state_subject('my-team') + expect(result.type).to eq(:users) + expect(result.user_ids).to include(user.id, teammate.id) + end + + it 'returns empty when user is not a team member' do + other_user = create(:user, person: create(:person)) + other_resolver = described_class.new(user: other_user) + result = other_resolver.resolve_state_subject('my-team') + expect(result.type).to eq(:empty) + end + end + end + + describe '#resolve_tag' do + context 'with plain tag name' do + it 'resolves to tag_name with nil user_ids (all accessible)' do + result = resolver.resolve_tag('review') + expect(result.tag_name).to eq('review') + expect(result.user_ids).to be_nil + expect(result.warnings).to be_empty + end + + it 'lowercases tag names' do + result = resolver.resolve_tag('Review') + expect(result.tag_name).to eq('review') + end + end + + context 'when not signed in' do + let(:resolver_no_user) { described_class.new(user: nil) } + + it 'returns empty with warning' do + result = resolver_no_user.resolve_tag('review') + expect(result.tag_name).to be_nil + expect(result.warnings).to include(/signed in/i) + end + end + end + + describe '#contributor_type?' do + it 'returns true for valid contributor types' do + expect(resolver.contributor_type?('contributor')).to be true + expect(resolver.contributor_type?('committer')).to be true + expect(resolver.contributor_type?('core_team')).to be true + end + + it 'returns false for non-contributor values' do + expect(resolver.contributor_type?('john')).to be false + expect(resolver.contributor_type?('me')).to be false + end + end + + describe '#email_value?' do + it 'returns true when value contains @' do + expect(resolver.email_value?('john@example.com')).to be true + expect(resolver.email_value?('@example.com')).to be true + end + + it 'returns false when value does not contain @' do + expect(resolver.email_value?('john')).to be false + expect(resolver.email_value?('John Doe')).to be false + end + end +end