rails-contact 0.1.16 → 0.1.18

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: b0b84870f841f736094084fa2ae0c08805f5b54a30fa8960b95cc04f14998001
4
+ data.tar.gz: 830dfc497510913b838651a00f430d36919c35fca155b54424dae053d4feba60
5
5
  SHA512:
6
- metadata.gz: c5b66c49e8c489d4565177a34ae71f210c1ccd8c4c36260aed0ef302e2a00ee766ac0c546f943c0f96bc2a843b3ba9c0a05b92518001487cae5a6f14de058714
7
- data.tar.gz: e133e1d5768a619f4a33c7a5d5033351358a5a0490d1f387c52730406e1e5b30eead92ed25f48928f01beba7262ab476300dac959571cc5263d1d0ea7e29762f
6
+ metadata.gz: bab63c0475d073586b14a681c7822f6185b4437e077b5bf3583fc7fa1c68f0ee581a4e7248d6498e77d72e0721c41e8105c188c56dfdeda54071573b9c4b33df
7
+ data.tar.gz: d46deab8ed814b053834de5157f76ef089efdc3fb04d630a03dbf85375b8686a89f70ab706b956b57f31bdf2152d2fcb9cca6352bccdecef310dfa21b8558486
data/CHANGELOG.md CHANGED
@@ -1,5 +1,39 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.18
4
+
5
+ - **The `city` filter is a multi-select.** `?city[]=Pune&city[]=Delhi` now
6
+ filters on several current cities at once, matching how `region` and
7
+ `csv_import_id` already behave. The database backend always handled an
8
+ array (`where(current_city: [...])`) — only the parameter permit was
9
+ scalar, which silently dropped every value but the last. Blank entries
10
+ (the hidden option a `<select multiple>` always submits) are stripped, and
11
+ an old single-value bookmark (`?city=Pune`) is coerced to a one-element
12
+ array, so no existing link changes meaning. The database backend now drops
13
+ blank entries from `city`/`region` itself as well, so a caller reaching it
14
+ directly gets "no filter" from an untouched multi-select instead of zero
15
+ rows.
16
+
17
+ ## 0.1.17
18
+
19
+ - **Database backend free-text search is now prefix search.** `q` matches
20
+ `LOWER(col) LIKE 'q%'` instead of `'%q%'`, so a plain btree
21
+ (`text_pattern_ops` on PostgreSQL) can serve every arm — the substring form
22
+ could use no index and forced a full scan of a 3-way-joined, `DISTINCT`ed
23
+ row set on multi-million-row tables. Match arms (given/family name, company,
24
+ job title, email, phone, label) run as a `UNION` of id-subqueries rather
25
+ than one `OR`, letting the planner drive each arm from its own index. Phone
26
+ input matches with and without the e164 `+`, so typing bare digits still
27
+ works. Trade-off: mid-string fragments no longer match ("`ave`" no longer
28
+ finds "Dave") — matching how operators actually hunt (start of a name,
29
+ email, or number).
30
+ - **Large result counts use the PostgreSQL planner estimate.** Exact
31
+ `COUNT(*)` walks every matching row on every page load; results at or above
32
+ 1,000 rows now take the row estimate from `EXPLAIN (FORMAT JSON)`
33
+ (milliseconds at any table size), while smaller results keep exact counts.
34
+ Non-PostgreSQL adapters and planner failures fall back to exact counting.
35
+ Estimates are display-only — never feed `total_count` into arithmetic.
36
+
3
37
  ## 0.1.16
4
38
 
5
39
  - **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.
@@ -136,7 +136,6 @@ module Rails
136
136
  metadata_scalars << :sort if config.metadata_sorts.any?
137
137
 
138
138
  permitted = params.permit(
139
- :city,
140
139
  :sync_eligible,
141
140
  :starred,
142
141
  :travel_date_start,
@@ -144,16 +143,17 @@ module Rails
144
143
  :contact_created_at_start,
145
144
  :contact_created_at_end,
146
145
  *metadata_scalars,
146
+ city: [],
147
147
  region: [],
148
148
  csv_import_id: [],
149
149
  **metadata_arrays.index_with { [] }
150
150
  )
151
151
 
152
- ([ :region, :csv_import_id ] + metadata_arrays).each { |key| normalize_multi_select!(permitted, key) }
152
+ ([ :city, :region, :csv_import_id ] + metadata_arrays).each { |key| normalize_multi_select!(permitted, key) }
153
153
  permitted
154
154
  end
155
155
 
156
- # region, csv_import_id and every configured :values metadata filter are
156
+ # city, region, csv_import_id and every configured :values metadata filter are
157
157
  # multi-selects: a <select multiple> submits param[] (an array) plus a
158
158
  # hidden param[]="" that Rails always sends, so the blank must be
159
159
  # stripped — otherwise IN ('', 'x') matches every blank-valued row.
@@ -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
@@ -60,8 +106,16 @@ module Rails
60
106
 
61
107
  def apply_filters(scope, filters)
62
108
  scoped = scope
63
- scoped = scoped.where(current_city: filters["city"]) if filters["city"].present?
64
- scoped = scoped.where(region_name: filters["region"]) if filters["region"].present?
109
+ # city and region are multi-selects: one value or many. Blanks are
110
+ # dropped here as well as in the controller — an untouched
111
+ # <select multiple> submits [""], and `where(col: [""])` would
112
+ # return nothing at all rather than "no city filter". A caller
113
+ # reaching the backend directly gets the same answer as one coming
114
+ # through filter_params.
115
+ %w[city region].zip(%i[current_city region_name]).each do |key, column|
116
+ values = Array(filters[key]).map(&:to_s).reject(&:blank?)
117
+ scoped = scoped.where(column => values) if values.any?
118
+ end
65
119
  scoped = scoped.where(starred: ActiveModel::Type::Boolean.new.cast(filters["starred"])) if filters["starred"].present?
66
120
  if filters["sync_eligible"].present?
67
121
  scoped = scoped.where(sync_eligible: ActiveModel::Type::Boolean.new.cast(filters["sync_eligible"]))
@@ -1,5 +1,5 @@
1
1
  module Rails
2
2
  module Contact
3
- VERSION = "0.1.16"
3
+ VERSION = "0.1.18"
4
4
  end
5
5
  end
@@ -4,16 +4,26 @@ RSpec.describe Rails::Contact::ContactsController do
4
4
  let(:controller) { described_class.new }
5
5
 
6
6
  describe "private filter params" do
7
- it "permits city/sync_eligible and coerces a scalar region to an array" do
7
+ # city joined region as a multi-select in 0.1.18, so a scalar from an old
8
+ # bookmark is coerced to a one-element array rather than kept as a string.
9
+ it "permits sync_eligible and coerces scalar city/region to arrays" do
8
10
  controller.params = ActionController::Parameters.new(city: "Delhi", region: "Europe", sync_eligible: "true", x: "1")
9
11
  permitted = controller.send(:filter_params)
10
- expect(permitted.to_h).to eq({ "city" => "Delhi", "region" => [ "Europe" ], "sync_eligible" => "true" })
12
+ expect(permitted.to_h).to eq({ "city" => [ "Delhi" ], "region" => [ "Europe" ], "sync_eligible" => "true" })
11
13
  end
12
14
 
13
- it "permits multi-select region[] and csv_import_id[] arrays" do
14
- controller.params = ActionController::Parameters.new(region: [ "Europe", "Asia" ], csv_import_id: [ "5", "7" ])
15
+ it "permits multi-select city[], region[] and csv_import_id[] arrays" do
16
+ controller.params = ActionController::Parameters.new(city: [ "Pune", "Delhi" ], region: [ "Europe", "Asia" ], csv_import_id: [ "5", "7" ])
15
17
  permitted = controller.send(:filter_params)
16
- expect(permitted.to_h).to eq({ "region" => [ "Europe", "Asia" ], "csv_import_id" => [ "5", "7" ] })
18
+ expect(permitted.to_h).to eq({ "city" => [ "Pune", "Delhi" ], "region" => [ "Europe", "Asia" ], "csv_import_id" => [ "5", "7" ] })
19
+ end
20
+
21
+ it "strips the blank a city multi-select submits, and drops it when only blanks arrive" do
22
+ controller.params = ActionController::Parameters.new(city: [ "", "Pune" ])
23
+ expect(controller.send(:filter_params).to_h).to eq({ "city" => [ "Pune" ] })
24
+
25
+ controller.params = ActionController::Parameters.new(city: [ "" ])
26
+ expect(controller.send(:filter_params).to_h).to eq({})
17
27
  end
18
28
 
19
29
  it "strips the hidden blank a <select multiple> submits" do
@@ -29,6 +29,28 @@ RSpec.describe Rails::Contact::Search::Backends::Database do
29
29
  end
30
30
  end
31
31
 
32
+ # City became a multi-select in 0.1.18. The backend always accepted an array
33
+ # here — what was missing was a permit that let one through.
34
+ describe "city filter (multi-select)" do
35
+ # Distinct from the factory default ("Delhi"), so alice/bob/carol above
36
+ # can't drift into these expectations.
37
+ let!(:pune) { create(:rails_contact_contact, given_name: "Pia", current_city: "Pune") }
38
+ let!(:jaipur) { create(:rails_contact_contact, given_name: "Dev", current_city: "Jaipur") }
39
+ let!(:kochi) { create(:rails_contact_contact, given_name: "Mira", current_city: "Kochi") }
40
+
41
+ it "matches contacts in every selected city (array -> IN)" do
42
+ expect(records("city" => [ "Pune", "Jaipur" ])).to match_array([ pune, jaipur ])
43
+ end
44
+
45
+ it "matches a single city exactly as before (scalar)" do
46
+ expect(records("city" => "Kochi")).to match_array([ kochi ])
47
+ end
48
+
49
+ it "applies no constraint when the selection is blank only" do
50
+ expect(records("city" => [ "" ]).count).to eq(Rails::Contact::Contact.count)
51
+ end
52
+ end
53
+
32
54
  describe "query sanitization" do
33
55
  def search_for(query)
34
56
  described_class.new.search(query, {}, page: 1, per_page: 25).records
@@ -46,4 +68,112 @@ RSpec.describe Rails::Contact::Search::Backends::Database do
46
68
  expect { search_for("a" * 1000) }.not_to raise_error
47
69
  end
48
70
  end
71
+
72
+ describe "prefix search arms" do
73
+ let!(:dave) do
74
+ create(:rails_contact_contact,
75
+ given_name: "Dave", family_name: "Sharma",
76
+ metadata: { "company" => "Acme Travels", "job_title" => "Planner" }.to_json).tap do |c|
77
+ c.emails.create!(value: "Dave@Example.com")
78
+ c.phones.create!(value: "+91 98123 45670", e164: "+919812345670")
79
+ c.labels = [ Rails::Contact::Label.find_or_create_by!(name: "vip-club") ]
80
+ end
81
+ end
82
+
83
+ def search_for(query)
84
+ described_class.new.search(query, {}, page: 1, per_page: 25).records
85
+ end
86
+
87
+ it "matches a given-name prefix case-insensitively" do
88
+ expect(search_for("dav")).to include(dave)
89
+ end
90
+
91
+ it "matches a family-name prefix" do
92
+ expect(search_for("sha")).to include(dave)
93
+ end
94
+
95
+ it "no longer matches a mid-string fragment (the index-serveable trade)" do
96
+ expect(search_for("ave")).not_to include(dave)
97
+ end
98
+
99
+ it "matches an email prefix" do
100
+ expect(search_for("dave@ex")).to include(dave)
101
+ end
102
+
103
+ it "matches bare digits against the +-prefixed e164" do
104
+ expect(search_for("91981")).to include(dave)
105
+ end
106
+
107
+ it "matches the full plus-form phone prefix" do
108
+ expect(search_for("+91981")).to include(dave)
109
+ end
110
+
111
+ it "matches a company prefix" do
112
+ expect(search_for("acme")).to include(dave)
113
+ end
114
+
115
+ it "matches a job-title prefix" do
116
+ expect(search_for("plan")).to include(dave)
117
+ end
118
+
119
+ it "matches a label prefix" do
120
+ expect(search_for("vip")).to include(dave)
121
+ end
122
+
123
+ it "does not duplicate a contact matched by several arms" do
124
+ # "dave" hits both the given_name and email arms; UNION dedupes ids.
125
+ expect(search_for("dave").count(dave)).to eq(1)
126
+ end
127
+ end
128
+
129
+ describe "result counting" do
130
+ let(:backend) { described_class.new }
131
+
132
+ it "counts exactly on non-PostgreSQL adapters" do
133
+ result = backend.search("", {}, page: 1, per_page: 25)
134
+ expect(result.total_count).to eq(Rails::Contact::Contact.count)
135
+ end
136
+
137
+ context "when the adapter reports PostgreSQL" do
138
+ before { allow(backend).to receive(:postgres?).and_return(true) }
139
+
140
+ it "uses the planner estimate at or above the threshold" do
141
+ allow(backend).to receive(:planner_estimate).and_return(50_000)
142
+
143
+ expect(backend.search("", {}, page: 1, per_page: 25).total_count).to eq(50_000)
144
+ end
145
+
146
+ it "counts exactly below the threshold" do
147
+ allow(backend).to receive(:planner_estimate).and_return(5)
148
+
149
+ expect(backend.search("", {}, page: 1, per_page: 25).total_count)
150
+ .to eq(Rails::Contact::Contact.count)
151
+ end
152
+
153
+ it "falls back to an exact count when the planner call fails" do
154
+ # No stub on planner_estimate: on this SQLite harness the real
155
+ # EXPLAIN (FORMAT JSON) raises and the rescue returns nil.
156
+ expect(backend.search("", {}, page: 1, per_page: 25).total_count)
157
+ .to eq(Rails::Contact::Contact.count)
158
+ end
159
+ end
160
+
161
+ describe "#planner_estimate" do
162
+ let(:scope) { Rails::Contact::Contact.all }
163
+
164
+ it "reads Plan Rows from EXPLAIN (FORMAT JSON)" do
165
+ allow(scope.klass.connection).to receive(:select_value)
166
+ .with(/\AEXPLAIN \(FORMAT JSON\)/)
167
+ .and_return('[{"Plan": {"Plan Rows": 123456}}]')
168
+
169
+ expect(backend.send(:planner_estimate, scope)).to eq(123_456)
170
+ end
171
+
172
+ it "returns nil on malformed planner output" do
173
+ allow(scope.klass.connection).to receive(:select_value).and_return("not json")
174
+
175
+ expect(backend.send(:planner_estimate, scope)).to be_nil
176
+ end
177
+ end
178
+ end
49
179
  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.18
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kshitiz Sinha