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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9a7f0cfd6a248a8763b84379dc244b151d7edc7328527586f0416d378f73e5ee
4
- data.tar.gz: 6be4ad0ae1d604fdf9ae66c3f8649573c4d2688ce3b93000bac1723fd08e616c
3
+ metadata.gz: 47e4ae3ee8279318a713c01eabbc5e85416f1f9847351aaf4fd56e3fe2ea51e1
4
+ data.tar.gz: c06b8d14fe450aa764ce09216db49c8a0a2877842099d0571cd965f2ff88ddd7
5
5
  SHA512:
6
- metadata.gz: c5b66c49e8c489d4565177a34ae71f210c1ccd8c4c36260aed0ef302e2a00ee766ac0c546f943c0f96bc2a843b3ba9c0a05b92518001487cae5a6f14de058714
7
- data.tar.gz: e133e1d5768a619f4a33c7a5d5033351358a5a0490d1f387c52730406e1e5b30eead92ed25f48928f01beba7262ab476300dac959571cc5263d1d0ea7e29762f
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
- # Metadata sort cannot combine with free-text search: the search
18
- # branch below runs SELECT DISTINCT, and PostgreSQL rejects ORDER BY
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
- if query.blank?
27
- total = scope.count
28
- records = scope.offset(offset).limit(per_page).to_a
29
- return Search::Result.new(records: records, total_count: total, page: page, per_page: per_page)
30
- end
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
@@ -1,5 +1,5 @@
1
1
  module Rails
2
2
  module Contact
3
- VERSION = "0.1.16"
3
+ VERSION = "0.1.17"
4
4
  end
5
5
  end
@@ -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
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rails-contact
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.16
4
+ version: 0.1.17
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kshitiz Sinha