Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement Balanced Full-Text Search for Diary Entries #5156

Draft
wants to merge 5 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
47 changes: 47 additions & 0 deletions app/controllers/diary_entries_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
10 changes: 10 additions & 0 deletions app/models/diary_entry.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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
15 changes: 15 additions & 0 deletions db/migrate/20240903180252_add_index_to_searchable_diary_entries.rb
Original file line number Diff line number Diff line change
@@ -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
12 changes: 11 additions & 1 deletion db/structure.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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
);


Expand Down Expand Up @@ -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: -
--
Expand Down Expand Up @@ -3349,6 +3357,8 @@ INSERT INTO "schema_migrations" (version) VALUES
('23'),
('22'),
('21'),
('20240903180252'),
('20240903014508'),
('20240822121603'),
('20240813070506'),
('20240724194738'),
Expand Down
Loading