Skip to content

Commit 6b4ba5a

Browse files
authored
Merge pull request #264 from joyofrails/feat/search-query-parser
Add search query parser
2 parents 0f8785e + b1c5656 commit 6b4ba5a

30 files changed

+460
-32
lines changed

Gemfile

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ gem "scout_apm" # Scout APM Ruby Agent [https://scoutapm.com]
3131
gem "rails_admin" # RailsAdmin is a Rails engine that provides an easy-to-use interface for managing your data [https://github.com/railsadminteam/rails_admin]
3232
gem "addressable" # Addressable is an alternative implementation to URI [https://github.com/sporkmonger/addressable]
3333
gem "ostruct" # OpenStruct is a data structure, similar to a Hash, that allows the definition of arbitrary attributes with their accompanying values
34+
gem "parslet" # Parslet is a small Ruby library for constructing parsers [https://github.com/kschiess/parslet]
3435

3536
# Rendering
3637
gem "image_processing" # Use Active Storage variants [https://guides.rubyonrails.org/active_storage_overview.html#transforming-images]

Gemfile.lock

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -337,6 +337,7 @@ GEM
337337
parser (3.3.5.0)
338338
ast (~> 2.4.1)
339339
racc
340+
parslet (2.0.0)
340341
phlex (2.0.0.beta2)
341342
phlex-rails (2.0.0.beta2)
342343
phlex (= 2.0.0.beta2)
@@ -621,6 +622,7 @@ DEPENDENCIES
621622
meta-tags
622623
mission_control-jobs
623624
ostruct
625+
parslet
624626
phlex (= 2.0.0.beta2)
625627
phlex-rails (= 2.0.0.beta2)
626628
postmark-rails
Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,20 @@
11
class SearchesController < ApplicationController
2+
rescue_from Searches::ParseFailed do |error|
3+
respond_to do |format|
4+
format.html { render Searches::ShowView.new(pages: [], query: @raw_query) }
5+
format.turbo_stream { render turbo_stream: turbo_stream.replace("search-results", plain: "No results") }
6+
end
7+
end
8+
29
def show
3-
query = params[:query] || ""
4-
pages = if query.present?
5-
Page.search(query).limit(3)
10+
@raw_query = params[:query] || ""
11+
pages = if @raw_query.present?
12+
query = Searches::Query.parse!(@raw_query)
13+
Page.search("#{query}*").with_snippets.ranked.limit(3)
614
else
7-
Page.limit(3)
15+
[]
816
end
917

10-
render Searches::ShowView.new(pages:, query:)
18+
render Searches::ShowView.new(pages:, query: @raw_query)
1119
end
1220
end

app/javascript/css/baseline.css

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -621,6 +621,10 @@ figcaption {
621621
padding: 0;
622622
}
623623

624+
mark {
625+
background-color: aqua;
626+
}
627+
624628
.dark {
625629
p {
626630
font-weight: 300;

app/javascript/css/components/combobox.css

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,6 @@
77

88
& a:hover,
99
& .selected {
10-
background-color: var(--joy-button-secondary);
10+
background-color: var(--joy-block-selected);
1111
}
1212
}

app/javascript/css/components/dialog.scss

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
@import '../config/variables.scss';
22

33
dialog {
4+
color: var(--joy-text);
45
width: 100%;
56
border: none;
67
animation: dialog-popup 0.25s ease-in-out;
@@ -24,7 +25,22 @@ dialog {
2425
}
2526

2627
input[type='search'] {
27-
border: 0;
28+
border: none;
29+
border-left: 1px solid var(--joy-border-quiet);
30+
color: var(--joy-text);
31+
}
32+
}
33+
34+
.dark {
35+
& dialog {
36+
border: 1px solid var(--joy-border-quiet);
37+
background-color: var(--color-gray-950);
38+
39+
& input[type='search'] {
40+
margin-left: 1rem;
41+
background-color: var(--color-gray-800);
42+
border: 1px solid var(--joy-border);
43+
}
2844
}
2945
}
3046

app/javascript/css/config/theme.css

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,8 @@
7676
--joy-link-decoration: var(--joy-link-1);
7777
--joy-link-active: var(--joy-link-5);
7878

79+
--joy-block-selected: var(--joy-color-200);
80+
7981
--joy-button-primary: var(--joy-color-600);
8082
--joy-button-primary-hover: var(--joy-color-700);
8183
--joy-button-primary-active: var(--joy-color-800);
@@ -127,6 +129,8 @@
127129
--joy-link-decoration: var(--joy-link-8);
128130
--joy-link-active: var(--joy-link-5);
129131

132+
--joy-block-selected: var(--joy-color-900);
133+
130134
--joy-button-transparent: none;
131135
--joy-button-transparent-hover: var(--joy-color-950);
132136
--joy-button-transparent-active: var(--joy-color-900);

app/javascript/css/layout.scss

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,10 @@ main > *,
9696
column-gap: var(--space-m);
9797
}
9898

99+
.col-gap-xs {
100+
column-gap: var(--space-xs);
101+
}
102+
99103
.col-gap-3xs {
100104
column-gap: var(--space-3xs);
101105
}

app/javascript/css/utilities/tailwind.css

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -699,6 +699,10 @@
699699
padding-top: 4rem;
700700
}
701701

702+
.pt-2 {
703+
padding-top: 0.5rem;
704+
}
705+
702706
.pt-4 {
703707
padding-top: 1rem;
704708
}

app/models/page.rb

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,37 @@
1+
# frozen_string_literal: true
2+
13
# ActiveRecord model to represent a static page in the database.
24
class Page < ApplicationRecord
35
after_create_commit :create_in_search_index
46
after_update_commit :update_in_search_index
57
after_destroy_commit :remove_from_search_index
68

7-
def self.rebuild_search_index
8-
find_each(&:update_in_search_index)
9+
# The pages_search_index table is a full-text search index for the pages
10+
# table. FTS5 tables contain a hidden 'rank' column that is (hand waving) a
11+
# relevancy score for sorting search results.
12+
# https://www.sqlite.org/fts5.html#sorting_by_auxiliary_function_results
13+
#
14+
scope :ranked, -> { order(:rank) }
15+
16+
# snippet() is a SQLite function that returns a snippet of text containing the search term.
17+
# It’s used to highlight search results. The snippet() function is not
18+
# available in all databases, so this scope is only available for SQLite as
19+
# part of the FTS5 extension.
20+
# https://www.sqlite.org/fts5.html#the_snippet_function
21+
#
22+
scope :with_snippets, ->(**options) do
23+
select("#{table_name}.*")
24+
.select("snippet(pages_search_index, 0, '<mark>', '</mark>', '…', 32) AS title_snippet")
25+
.select("snippet(pages_search_index, 1, '<mark>', '</mark>', '…', 32) AS body_snippet")
926
end
1027

1128
def self.search(query)
1229
joins("JOIN pages_search_index ON pages.id = pages_search_index.page_id")
13-
.where("pages_search_index MATCH ?", query.to_s.gsub(/\W/, " ") + "*")
30+
.where("pages_search_index MATCH ?", query)
31+
end
32+
33+
def self.rebuild_search_index
34+
find_each(&:update_in_search_index)
1435
end
1536

1637
def title

0 commit comments

Comments
 (0)