diff --git a/Gemfile b/Gemfile index 27f295eb65..821b43d692 100644 --- a/Gemfile +++ b/Gemfile @@ -10,6 +10,9 @@ gem "json" # Use postgres as the database gem "pg" +# Use postgres search +gem "pg_search" + # Use SCSS for stylesheets gem "dartsass-sprockets" # Pin the dependentent sass-embedded to avoid deprecation warnings in bootstrap diff --git a/Gemfile.lock b/Gemfile.lock index a34976cd66..5f47ba6fbb 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -414,6 +414,9 @@ GEM ast (~> 2.4.1) racc pg (1.5.7) + pg_search (2.3.7) + activerecord (>= 6.1) + activesupport (>= 6.1) popper_js (2.11.8) progress (3.6.0) psych (5.1.2) @@ -680,6 +683,7 @@ DEPENDENCIES openstreetmap-deadlock_retry (>= 1.3.1) overcommit pg + pg_search puma (~> 5.6) quad_tile (~> 1.0.1) rack-cors diff --git a/app/controllers/diary_entries_controller.rb b/app/controllers/diary_entries_controller.rb index ff6dfc826c..1db7a0f002 100644 --- a/app/controllers/diary_entries_controller.rb +++ b/app/controllers/diary_entries_controller.rb @@ -48,6 +48,9 @@ def index if params[:language] @title = t ".in_language_title", :language => Language.find(params[:language]).english_name entries = entries.where(:language_code => params[:language]) + elsif params[:query].present? + @title = t ".search_results_title" + entries = DiaryEntry.search_by_title_and_body(params[:query]) else candidate_codes = preferred_languages.flat_map(&:candidates).uniq.map(&:to_s) @languages = Language.where(:code => candidate_codes).in_order_of(:code, candidate_codes) @@ -241,3 +244,47 @@ def set_map_location end end end + +def test_index_search + user = create(:user) + create(:diary_entry, :user => user, :title => "First Entry", :body => "This is the first diary entry.") + create(:diary_entry, :user => user, :title => "Second Entry", :body => "This is the second diary entry.") + create(:diary_entry, :user => user, :title => "Third Entry", :body => "Nothing related to the search term.") + + # Test search by title + get diary_entries_path(:query => "First") + assert_response :success + assert_select "article.diary_post", 1 + assert_select "h2", :text => /First Entry/ + + # Test search by body + get diary_entries_path(:query => "second") + assert_response :success + assert_select "article.diary_post", 1 + assert_select "h2", :text => /Second Entry/ + + # Test no results + get diary_entries_path(:query => "Nonexistent") + assert_response :success + assert_select "h4", :text => /No entries/, :count => 1 +end + +def test_index_search_language + user = create(:user) + create(:language, :code => "en") + create(:language, :code => "de") + + create(:diary_entry, :user => user, :title => "English Entry", :language_code => "en") + create(:diary_entry, :user => user, :title => "German Entry", :language_code => "de") + + # Search within a specific language + get diary_entries_path(:query => "Entry", :language => "en") + assert_response :success + assert_select "article.diary_post", 1 + assert_select "h2", :text => /English Entry/ + + get diary_entries_path(:query => "Entry", :language => "de") + assert_response :success + assert_select "article.diary_post", 1 + assert_select "h2", :text => /German Entry/ +end diff --git a/app/models/diary_entry.rb b/app/models/diary_entry.rb index 089c7e6c60..9367e578f7 100644 --- a/app/models/diary_entry.rb +++ b/app/models/diary_entry.rb @@ -35,6 +35,16 @@ class DiaryEntry < ApplicationRecord has_many :subscriptions, :class_name => "DiaryEntrySubscription" has_many :subscribers, :through => :subscriptions, :source => :user + include PgSearch::Model + pg_search_scope :search_by_title_and_body, + :against => [:title, :body], + :using => { + :tsearch => { + :dictionary => "simple", + :tsvector_column => "searchable" + } + } + scope :visible, -> { where(:visible => true) } validates :title, :presence => true, :length => 1..255, :characters => true diff --git a/db/migrate/20240903014508_add_searchable_column_to_diary_entries.rb b/db/migrate/20240903014508_add_searchable_column_to_diary_entries.rb new file mode 100644 index 0000000000..56150650f5 --- /dev/null +++ b/db/migrate/20240903014508_add_searchable_column_to_diary_entries.rb @@ -0,0 +1,22 @@ +class AddSearchableColumnToDiaryEntries < ActiveRecord::Migration[7.1] + def up + safety_assured do + execute <<-SQL.squish + ALTER TABLE diary_entries + ADD COLUMN searchable tsvector GENERATED ALWAYS AS ( + setweight(to_tsvector('simple', coalesce(title, '')), 'A') || + setweight(to_tsvector('simple', coalesce(body, '')), 'B') + ) STORED; + SQL + end + end + + def down + safety_assured do + execute <<-SQL.squish + ALTER TABLE diary_entries + DROP COLUMN IF EXISTS searchable; + SQL + end + end +end diff --git a/db/migrate/20240903180252_add_index_to_searchable_diary_entries.rb b/db/migrate/20240903180252_add_index_to_searchable_diary_entries.rb new file mode 100644 index 0000000000..c1a8037e11 --- /dev/null +++ b/db/migrate/20240903180252_add_index_to_searchable_diary_entries.rb @@ -0,0 +1,15 @@ +class AddIndexToSearchableDiaryEntries < ActiveRecord::Migration[7.1] + disable_ddl_transaction! + + def up + safety_assured do + add_index :diary_entries, :searchable, :using => :gin, :algorithm => :concurrently + end + end + + def down + safety_assured do + remove_index :diary_entries, :searchable, :algorithm => :concurrently + end + end +end diff --git a/db/structure.sql b/db/structure.sql index f9e588b3c2..eced2b3297 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -717,7 +717,8 @@ CREATE TABLE public.diary_entries ( longitude double precision, language_code character varying DEFAULT 'en'::character varying NOT NULL, visible boolean DEFAULT true NOT NULL, - body_format public.format_enum DEFAULT 'markdown'::public.format_enum NOT NULL + body_format public.format_enum DEFAULT 'markdown'::public.format_enum NOT NULL, + searchable tsvector GENERATED ALWAYS AS ((setweight(to_tsvector('simple'::regconfig, (COALESCE(title, ''::character varying))::text), 'A'::"char") || setweight(to_tsvector('simple'::regconfig, COALESCE(body, ''::text)), 'B'::"char"))) STORED ); @@ -2414,6 +2415,13 @@ CREATE INDEX index_changesets_subscribers_on_changeset_id ON public.changesets_s CREATE UNIQUE INDEX index_changesets_subscribers_on_subscriber_id_and_changeset_id ON public.changesets_subscribers USING btree (subscriber_id, changeset_id); +-- +-- Name: index_diary_entries_on_searchable; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_diary_entries_on_searchable ON public.diary_entries USING gin (searchable); + + -- -- Name: index_diary_entry_subscriptions_on_diary_entry_id; Type: INDEX; Schema: public; Owner: - -- @@ -3349,6 +3357,8 @@ INSERT INTO "schema_migrations" (version) VALUES ('23'), ('22'), ('21'), +('20240903180252'), +('20240903014508'), ('20240822121603'), ('20240813070506'), ('20240724194738'),