rails-contact 0.1.16 → 0.1.17
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +20 -0
- data/lib/rails/contact/search/backends/database.rb +71 -25
- data/lib/rails/contact/version.rb +1 -1
- data/spec/search/database_backend_spec.rb +108 -0
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 47e4ae3ee8279318a713c01eabbc5e85416f1f9847351aaf4fd56e3fe2ea51e1
|
|
4
|
+
data.tar.gz: c06b8d14fe450aa764ce09216db49c8a0a2877842099d0571cd965f2ff88ddd7
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ace1203d3ff1d490b519b14175088dc483ac71780bf1186a233ef6e31a522927df60d6d95c8ee25ea9f6074d53a7a4887ae9b8cc8af9aab67119269e3eec3f85
|
|
7
|
+
data.tar.gz: ad9e1d740f6cd540a3528accaa1cf3e35d898df89da18312916ee3ba23b79cd6c93ab28a347eff56fa39335bbe464b3e69343a16c930c521bde8926646a2d712
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,25 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.1.17
|
|
4
|
+
|
|
5
|
+
- **Database backend free-text search is now prefix search.** `q` matches
|
|
6
|
+
`LOWER(col) LIKE 'q%'` instead of `'%q%'`, so a plain btree
|
|
7
|
+
(`text_pattern_ops` on PostgreSQL) can serve every arm — the substring form
|
|
8
|
+
could use no index and forced a full scan of a 3-way-joined, `DISTINCT`ed
|
|
9
|
+
row set on multi-million-row tables. Match arms (given/family name, company,
|
|
10
|
+
job title, email, phone, label) run as a `UNION` of id-subqueries rather
|
|
11
|
+
than one `OR`, letting the planner drive each arm from its own index. Phone
|
|
12
|
+
input matches with and without the e164 `+`, so typing bare digits still
|
|
13
|
+
works. Trade-off: mid-string fragments no longer match ("`ave`" no longer
|
|
14
|
+
finds "Dave") — matching how operators actually hunt (start of a name,
|
|
15
|
+
email, or number).
|
|
16
|
+
- **Large result counts use the PostgreSQL planner estimate.** Exact
|
|
17
|
+
`COUNT(*)` walks every matching row on every page load; results at or above
|
|
18
|
+
1,000 rows now take the row estimate from `EXPLAIN (FORMAT JSON)`
|
|
19
|
+
(milliseconds at any table size), while smaller results keep exact counts.
|
|
20
|
+
Non-PostgreSQL adapters and planner failures fall back to exact counting.
|
|
21
|
+
Estimates are display-only — never feed `total_count` into arithmetic.
|
|
22
|
+
|
|
3
23
|
## 0.1.16
|
|
4
24
|
|
|
5
25
|
- **New metadata filter type `:exclude`** — hides rows whose metadata key equals a configured value, e.g. `{ key: "authenticity", type: :exclude, value: "test", default: :on }`. With `default: :on` the filter applies even when the request param is absent (first page load, bookmarks) and an explicit false-y value (`"0"`) switches it off — built for default-on "hide test data" checkboxes. Rows missing the key always pass, so unclassified legacy data is never hidden.
|
|
@@ -14,40 +14,86 @@ module Rails
|
|
|
14
14
|
|
|
15
15
|
def search(query, filters, page:, per_page:)
|
|
16
16
|
query = sanitize_query(query)
|
|
17
|
-
#
|
|
18
|
-
#
|
|
19
|
-
# expressions that are not in the select list. Searching means
|
|
20
|
-
# hunting a specific contact anyway, so q results keep recency order.
|
|
17
|
+
# Free-text search keeps recency order: searching means hunting a
|
|
18
|
+
# specific contact, so a metadata sort would only bury the match.
|
|
21
19
|
filters = filters.except("sort") if query.present?
|
|
22
20
|
offset = (page - 1) * per_page
|
|
23
21
|
scope = Contact.includes(:emails, :phones, :labels).recent_first
|
|
24
22
|
scope = apply_filters(scope, filters)
|
|
23
|
+
scope = apply_query(scope, query) if query.present?
|
|
25
24
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
wildcard = "%#{query.downcase}%"
|
|
33
|
-
filtered = scope.left_joins(:emails, :phones, :labels).where(
|
|
34
|
-
"LOWER(rails_contact_contacts.given_name) LIKE :q OR "\
|
|
35
|
-
"LOWER(rails_contact_contacts.family_name) LIKE :q OR "\
|
|
36
|
-
"LOWER(COALESCE(rails_contact_contacts.metadata->>'company', '')) LIKE :q OR "\
|
|
37
|
-
"LOWER(COALESCE(rails_contact_contacts.metadata->>'job_title', '')) LIKE :q OR "\
|
|
38
|
-
"LOWER(rails_contact_contact_emails.value) LIKE :q OR "\
|
|
39
|
-
"rails_contact_contact_phones.e164 LIKE :raw OR "\
|
|
40
|
-
"LOWER(rails_contact_labels.name) LIKE :q",
|
|
41
|
-
q: wildcard,
|
|
42
|
-
raw: "%#{query}%"
|
|
43
|
-
).distinct
|
|
44
|
-
total = filtered.count(:id)
|
|
45
|
-
records = filtered.offset(offset).limit(per_page).to_a
|
|
46
|
-
Search::Result.new(records: records, total_count: total, page: page, per_page: per_page)
|
|
25
|
+
Search::Result.new(
|
|
26
|
+
records: scope.offset(offset).limit(per_page).to_a,
|
|
27
|
+
total_count: count_for(scope),
|
|
28
|
+
page: page,
|
|
29
|
+
per_page: per_page
|
|
30
|
+
)
|
|
47
31
|
end
|
|
48
32
|
|
|
49
33
|
private
|
|
50
34
|
|
|
35
|
+
# Prefix search, deliberately: LOWER(col) LIKE 'q%' is served by a
|
|
36
|
+
# plain btree (text_pattern_ops on PostgreSQL), where the previous
|
|
37
|
+
# '%q%' substring form could use no index at all and forced a full
|
|
38
|
+
# scan of a 3-way-joined, DISTINCTed row set on a multi-million-row
|
|
39
|
+
# table.
|
|
40
|
+
#
|
|
41
|
+
# Each match arm lives in its own subquery UNIONed by id rather than
|
|
42
|
+
# OR'd into one WHERE: an OR mixing table columns and EXISTS probes
|
|
43
|
+
# can never use a bitmap-index combination, but a UNION of id-sets
|
|
44
|
+
# lets the planner drive every arm from its own index and semi-join
|
|
45
|
+
# the small result against the ordered contact scan. Phone numbers
|
|
46
|
+
# are probed with and without the e164 '+' so typing bare digits
|
|
47
|
+
# still matches.
|
|
48
|
+
def apply_query(scoped, query)
|
|
49
|
+
prefix = "#{query.downcase}%"
|
|
50
|
+
scoped.where(
|
|
51
|
+
"rails_contact_contacts.id IN (" \
|
|
52
|
+
"SELECT c.id FROM rails_contact_contacts c WHERE LOWER(c.given_name) LIKE :q " \
|
|
53
|
+
"UNION SELECT c.id FROM rails_contact_contacts c WHERE LOWER(c.family_name) LIKE :q " \
|
|
54
|
+
"UNION SELECT c.id FROM rails_contact_contacts c WHERE LOWER(COALESCE(c.metadata->>'company', '')) LIKE :q " \
|
|
55
|
+
"UNION SELECT c.id FROM rails_contact_contacts c WHERE LOWER(COALESCE(c.metadata->>'job_title', '')) LIKE :q " \
|
|
56
|
+
"UNION SELECT e.contact_id FROM rails_contact_contact_emails e WHERE LOWER(e.value) LIKE :q " \
|
|
57
|
+
"UNION SELECT p.contact_id FROM rails_contact_contact_phones p WHERE p.e164 LIKE :raw OR p.e164 LIKE :plus_raw " \
|
|
58
|
+
"UNION SELECT cl.contact_id FROM rails_contact_contact_labels cl " \
|
|
59
|
+
"JOIN rails_contact_labels l ON l.id = cl.label_id WHERE LOWER(l.name) LIKE :q" \
|
|
60
|
+
")",
|
|
61
|
+
q: prefix,
|
|
62
|
+
raw: "#{query}%",
|
|
63
|
+
plus_raw: "+#{query}%"
|
|
64
|
+
)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Exact COUNT(*) walks every matching row and was one of the two
|
|
68
|
+
# full-table passes behind 40-second index pages. On PostgreSQL,
|
|
69
|
+
# large counts come from the planner's row estimate instead —
|
|
70
|
+
# milliseconds regardless of table size. Small results (under
|
|
71
|
+
# APPROX_COUNT_THRESHOLD) still count exactly: cheap to do, and
|
|
72
|
+
# operators expect precise numbers on short lists. Estimates are
|
|
73
|
+
# for pager display only — never feed them into arithmetic.
|
|
74
|
+
APPROX_COUNT_THRESHOLD = 1_000
|
|
75
|
+
|
|
76
|
+
def count_for(scoped)
|
|
77
|
+
return scoped.count unless postgres?(scoped)
|
|
78
|
+
|
|
79
|
+
estimate = planner_estimate(scoped)
|
|
80
|
+
return scoped.count if estimate.nil? || estimate < APPROX_COUNT_THRESHOLD
|
|
81
|
+
|
|
82
|
+
estimate
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# EXPLAIN (FORMAT JSON) without ANALYZE executes nothing; the
|
|
86
|
+
# relation's own to_sql carries its bound values inlined, so there
|
|
87
|
+
# is no injection surface beyond what the scope already is.
|
|
88
|
+
# Estimates track table statistics, so they are only as fresh as
|
|
89
|
+
# the last ANALYZE.
|
|
90
|
+
def planner_estimate(scoped)
|
|
91
|
+
plan = scoped.klass.connection.select_value("EXPLAIN (FORMAT JSON) #{scoped.to_sql}")
|
|
92
|
+
JSON.parse(plan.to_s).dig(0, "Plan", "Plan Rows")
|
|
93
|
+
rescue ActiveRecord::StatementInvalid, JSON::ParserError
|
|
94
|
+
nil
|
|
95
|
+
end
|
|
96
|
+
|
|
51
97
|
# Escape LIKE metacharacters (% _ \) so a user typing "%" can't widen
|
|
52
98
|
# the match to every row, and cap length to keep the pattern bounded.
|
|
53
99
|
# The backend builds raw "%…%" LIKE patterns, so this guard belongs
|
|
@@ -46,4 +46,112 @@ RSpec.describe Rails::Contact::Search::Backends::Database do
|
|
|
46
46
|
expect { search_for("a" * 1000) }.not_to raise_error
|
|
47
47
|
end
|
|
48
48
|
end
|
|
49
|
+
|
|
50
|
+
describe "prefix search arms" do
|
|
51
|
+
let!(:dave) do
|
|
52
|
+
create(:rails_contact_contact,
|
|
53
|
+
given_name: "Dave", family_name: "Sharma",
|
|
54
|
+
metadata: { "company" => "Acme Travels", "job_title" => "Planner" }.to_json).tap do |c|
|
|
55
|
+
c.emails.create!(value: "Dave@Example.com")
|
|
56
|
+
c.phones.create!(value: "+91 98123 45670", e164: "+919812345670")
|
|
57
|
+
c.labels = [ Rails::Contact::Label.find_or_create_by!(name: "vip-club") ]
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def search_for(query)
|
|
62
|
+
described_class.new.search(query, {}, page: 1, per_page: 25).records
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
it "matches a given-name prefix case-insensitively" do
|
|
66
|
+
expect(search_for("dav")).to include(dave)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
it "matches a family-name prefix" do
|
|
70
|
+
expect(search_for("sha")).to include(dave)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
it "no longer matches a mid-string fragment (the index-serveable trade)" do
|
|
74
|
+
expect(search_for("ave")).not_to include(dave)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
it "matches an email prefix" do
|
|
78
|
+
expect(search_for("dave@ex")).to include(dave)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
it "matches bare digits against the +-prefixed e164" do
|
|
82
|
+
expect(search_for("91981")).to include(dave)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
it "matches the full plus-form phone prefix" do
|
|
86
|
+
expect(search_for("+91981")).to include(dave)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
it "matches a company prefix" do
|
|
90
|
+
expect(search_for("acme")).to include(dave)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
it "matches a job-title prefix" do
|
|
94
|
+
expect(search_for("plan")).to include(dave)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
it "matches a label prefix" do
|
|
98
|
+
expect(search_for("vip")).to include(dave)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
it "does not duplicate a contact matched by several arms" do
|
|
102
|
+
# "dave" hits both the given_name and email arms; UNION dedupes ids.
|
|
103
|
+
expect(search_for("dave").count(dave)).to eq(1)
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
describe "result counting" do
|
|
108
|
+
let(:backend) { described_class.new }
|
|
109
|
+
|
|
110
|
+
it "counts exactly on non-PostgreSQL adapters" do
|
|
111
|
+
result = backend.search("", {}, page: 1, per_page: 25)
|
|
112
|
+
expect(result.total_count).to eq(Rails::Contact::Contact.count)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
context "when the adapter reports PostgreSQL" do
|
|
116
|
+
before { allow(backend).to receive(:postgres?).and_return(true) }
|
|
117
|
+
|
|
118
|
+
it "uses the planner estimate at or above the threshold" do
|
|
119
|
+
allow(backend).to receive(:planner_estimate).and_return(50_000)
|
|
120
|
+
|
|
121
|
+
expect(backend.search("", {}, page: 1, per_page: 25).total_count).to eq(50_000)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
it "counts exactly below the threshold" do
|
|
125
|
+
allow(backend).to receive(:planner_estimate).and_return(5)
|
|
126
|
+
|
|
127
|
+
expect(backend.search("", {}, page: 1, per_page: 25).total_count)
|
|
128
|
+
.to eq(Rails::Contact::Contact.count)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
it "falls back to an exact count when the planner call fails" do
|
|
132
|
+
# No stub on planner_estimate: on this SQLite harness the real
|
|
133
|
+
# EXPLAIN (FORMAT JSON) raises and the rescue returns nil.
|
|
134
|
+
expect(backend.search("", {}, page: 1, per_page: 25).total_count)
|
|
135
|
+
.to eq(Rails::Contact::Contact.count)
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
describe "#planner_estimate" do
|
|
140
|
+
let(:scope) { Rails::Contact::Contact.all }
|
|
141
|
+
|
|
142
|
+
it "reads Plan Rows from EXPLAIN (FORMAT JSON)" do
|
|
143
|
+
allow(scope.klass.connection).to receive(:select_value)
|
|
144
|
+
.with(/\AEXPLAIN \(FORMAT JSON\)/)
|
|
145
|
+
.and_return('[{"Plan": {"Plan Rows": 123456}}]')
|
|
146
|
+
|
|
147
|
+
expect(backend.send(:planner_estimate, scope)).to eq(123_456)
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
it "returns nil on malformed planner output" do
|
|
151
|
+
allow(scope.klass.connection).to receive(:select_value).and_return("not json")
|
|
152
|
+
|
|
153
|
+
expect(backend.send(:planner_estimate, scope)).to be_nil
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
|
49
157
|
end
|