rails-contact 0.1.17 → 0.1.19

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: 47e4ae3ee8279318a713c01eabbc5e85416f1f9847351aaf4fd56e3fe2ea51e1
4
- data.tar.gz: c06b8d14fe450aa764ce09216db49c8a0a2877842099d0571cd965f2ff88ddd7
3
+ metadata.gz: f986b7426ce2b99924283ea2343ef48ed1f6f4c50c6e2763ba50f03be99b9a93
4
+ data.tar.gz: 8f04a3f04fdb7d9bb6921efa14df565be9b82029c03c522da8ac6b111e70e3f9
5
5
  SHA512:
6
- metadata.gz: ace1203d3ff1d490b519b14175088dc483ac71780bf1186a233ef6e31a522927df60d6d95c8ee25ea9f6074d53a7a4887ae9b8cc8af9aab67119269e3eec3f85
7
- data.tar.gz: ad9e1d740f6cd540a3528accaa1cf3e35d898df89da18312916ee3ba23b79cd6c93ab28a347eff56fa39335bbe464b3e69343a16c930c521bde8926646a2d712
6
+ metadata.gz: 7426fa7a4faf079dba58377b115f5b612463f55c7ab66aef7f4eec65fbb8b258c84dc91c148d63bd9b76e59bc08387b8df34052f4226ee066cde9eeba1eec916
7
+ data.tar.gz: 916da6b6432f11a44c8b260964f81feaa5092b2da83d4a5c9433d25c786d4e57d19d1b6f402a1c2ddaf043a8a8dbb69ea0da7308d82016323393d398e0a7b072
data/CHANGELOG.md CHANGED
@@ -1,5 +1,37 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.19
4
+
5
+ - **Result counts are exact again, and bounded.** 0.1.17 replaced large exact
6
+ counts with the PostgreSQL planner's row estimate, which the index then
7
+ printed as if it were a count: a filtered list of 364 contacts reported
8
+ "Showing 1-25 of 1,005" across 41 pages, most of them empty, and host apps
9
+ reading `total_count` sized outbound campaigns off the same guess. Estimates
10
+ are worst exactly where they matter - a value the last `ANALYZE` has not
11
+ sampled (a freshly imported batch) falls back to `rows / n_distinct`.
12
+ `count_for` now runs `COUNT(*)` over the matching rows capped at
13
+ `MAX_EXACT_COUNT + 1` (10,000), with `ORDER BY` and eager-loading stripped
14
+ from the subquery so the database stops scanning at the `LIMIT` - the
15
+ full-table pass the estimate existed to avoid never happens, and every
16
+ realistic filtered list gets a true number. Past the cap,
17
+ `Search::Result#count_capped?` is true, `total_count` is the cap, and the
18
+ new `contact_count_label` helper renders it as "10,000+". `@count_capped` is
19
+ exposed to views alongside `@total_count`.
20
+
21
+ ## 0.1.18
22
+
23
+ - **The `city` filter is a multi-select.** `?city[]=Pune&city[]=Delhi` now
24
+ filters on several current cities at once, matching how `region` and
25
+ `csv_import_id` already behave. The database backend always handled an
26
+ array (`where(current_city: [...])`) — only the parameter permit was
27
+ scalar, which silently dropped every value but the last. Blank entries
28
+ (the hidden option a `<select multiple>` always submits) are stripped, and
29
+ an old single-value bookmark (`?city=Pune`) is coerced to a one-element
30
+ array, so no existing link changes meaning. The database backend now drops
31
+ blank entries from `city`/`region` itself as well, so a caller reaching it
32
+ directly gets "no filter" from an untouched multi-select instead of zero
33
+ rows.
34
+
3
35
  ## 0.1.17
4
36
 
5
37
  - **Database backend free-text search is now prefix search.** `q` matches
@@ -17,6 +17,9 @@ module Rails
17
17
  ).call
18
18
  @contacts = result.records
19
19
  @total_count = result.total_count
20
+ # A capped count is a floor, not a total — the pager and any audience
21
+ # panel built on @total_count must render it as "N+".
22
+ @count_capped = result.count_capped?
20
23
  @page = result.page
21
24
  @per_page = result.per_page
22
25
  @total_pages = result.total_pages
@@ -136,7 +139,6 @@ module Rails
136
139
  metadata_scalars << :sort if config.metadata_sorts.any?
137
140
 
138
141
  permitted = params.permit(
139
- :city,
140
142
  :sync_eligible,
141
143
  :starred,
142
144
  :travel_date_start,
@@ -144,16 +146,17 @@ module Rails
144
146
  :contact_created_at_start,
145
147
  :contact_created_at_end,
146
148
  *metadata_scalars,
149
+ city: [],
147
150
  region: [],
148
151
  csv_import_id: [],
149
152
  **metadata_arrays.index_with { [] }
150
153
  )
151
154
 
152
- ([ :region, :csv_import_id ] + metadata_arrays).each { |key| normalize_multi_select!(permitted, key) }
155
+ ([ :city, :region, :csv_import_id ] + metadata_arrays).each { |key| normalize_multi_select!(permitted, key) }
153
156
  permitted
154
157
  end
155
158
 
156
- # region, csv_import_id and every configured :values metadata filter are
159
+ # city, region, csv_import_id and every configured :values metadata filter are
157
160
  # multi-selects: a <select multiple> submits param[] (an array) plus a
158
161
  # hidden param[]="" that Rails always sends, so the blank must be
159
162
  # stripped — otherwise IN ('', 'x') matches every blank-valued row.
@@ -5,6 +5,13 @@ module Rails
5
5
  [ contact.given_name, contact.family_name ].map { |part| part.to_s.first.to_s.upcase }.join
6
6
  end
7
7
 
8
+ # A count the backend stopped at its cap is a floor, not a total, so it
9
+ # renders as "10,000+". Rendering it plain reads as an exact number and
10
+ # is how a guessed 1,005 once sized a WhatsApp broadcast.
11
+ def contact_count_label(count, capped = false)
12
+ "#{number_with_delimiter(count.to_i)}#{'+' if capped}"
13
+ end
14
+
8
15
  def contact_chip(value)
9
16
  value.presence || "-"
10
17
  end
@@ -4,7 +4,7 @@
4
4
  <% start_i = (@page - 1) * @per_page + 1 %>
5
5
  <% end_i = [ @page * @per_page, @total_count ].min %>
6
6
  Showing <span class="font-medium text-gray-900"><%= start_i %>–<%= end_i %></span>
7
- of <span class="font-medium text-gray-900"><%= number_with_delimiter(@total_count) %></span>
7
+ of <span class="font-medium text-gray-900"><%= contact_count_label(@total_count, @count_capped) %></span>
8
8
  <% if @per_page.present? && @per_page > 0 %>
9
9
  <span class="text-gray-500">(<%= @per_page %> per page)</span>
10
10
  <% end %>
@@ -22,9 +22,12 @@ module Rails
22
22
  scope = apply_filters(scope, filters)
23
23
  scope = apply_query(scope, query) if query.present?
24
24
 
25
+ total_count, count_capped = count_for(scope)
26
+
25
27
  Search::Result.new(
26
28
  records: scope.offset(offset).limit(per_page).to_a,
27
- total_count: count_for(scope),
29
+ total_count: total_count,
30
+ count_capped: count_capped,
28
31
  page: page,
29
32
  per_page: per_page
30
33
  )
@@ -65,33 +68,33 @@ module Rails
65
68
  end
66
69
 
67
70
  # 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
-
71
+ # full-table passes behind 40-second index pages; the planner
72
+ # estimate that replaced it then printed guesses as counts — a sheet
73
+ # of 364 contacts read "1,005 · Page 1 / 41", and the same number
74
+ # sized a WhatsApp broadcast. So count exactly, but bounded:
75
+ # COUNT(*) over the matching rows capped at MAX_EXACT_COUNT + 1 lets
76
+ # the database stop scanning at the LIMIT, so no view pays a
77
+ # full-table pass, while every realistic filtered list gets a true
78
+ # number. Past the cap the caller is told so and shows "10,000+".
79
+ MAX_EXACT_COUNT = 10_000
80
+
81
+ # Returns [count, capped]. count never exceeds MAX_EXACT_COUNT.
76
82
  def count_for(scoped)
77
- return scoped.count unless postgres?(scoped)
83
+ total = scoped.klass.unscoped.from(bounded_scope(scoped), :bounded_count).count
78
84
 
79
- estimate = planner_estimate(scoped)
80
- return scoped.count if estimate.nil? || estimate < APPROX_COUNT_THRESHOLD
81
-
82
- estimate
85
+ [ [ total, MAX_EXACT_COUNT ].min, total > MAX_EXACT_COUNT ]
83
86
  end
84
87
 
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
88
+ # ORDER BY is stripped rather than kept: recent_first orders by
89
+ # updated_at and a metadata sort is a CASE expression — either one
90
+ # makes the planner sort the whole matching set before it can honour
91
+ # the LIMIT, which is the full pass this is here to avoid. SELECT 1
92
+ # keeps it a bare row scan, and eager-loading has no bearing on how
93
+ # many rows match.
94
+ def bounded_scope(scoped)
95
+ scoped.except(:order, :includes, :eager_load, :preload)
96
+ .select("1")
97
+ .limit(MAX_EXACT_COUNT + 1)
95
98
  end
96
99
 
97
100
  # Escape LIKE metacharacters (% _ \) so a user typing "%" can't widen
@@ -106,8 +109,16 @@ module Rails
106
109
 
107
110
  def apply_filters(scope, filters)
108
111
  scoped = scope
109
- scoped = scoped.where(current_city: filters["city"]) if filters["city"].present?
110
- scoped = scoped.where(region_name: filters["region"]) if filters["region"].present?
112
+ # city and region are multi-selects: one value or many. Blanks are
113
+ # dropped here as well as in the controller — an untouched
114
+ # <select multiple> submits [""], and `where(col: [""])` would
115
+ # return nothing at all rather than "no city filter". A caller
116
+ # reaching the backend directly gets the same answer as one coming
117
+ # through filter_params.
118
+ %w[city region].zip(%i[current_city region_name]).each do |key, column|
119
+ values = Array(filters[key]).map(&:to_s).reject(&:blank?)
120
+ scoped = scoped.where(column => values) if values.any?
121
+ end
111
122
  scoped = scoped.where(starred: ActiveModel::Type::Boolean.new.cast(filters["starred"])) if filters["starred"].present?
112
123
  if filters["sync_eligible"].present?
113
124
  scoped = scoped.where(sync_eligible: ActiveModel::Type::Boolean.new.cast(filters["sync_eligible"]))
@@ -1,7 +1,14 @@
1
1
  module Rails
2
2
  module Contact
3
3
  module Search
4
- Result = Struct.new(:records, :total_count, :page, :per_page, keyword_init: true) do
4
+ Result = Struct.new(:records, :total_count, :page, :per_page, :count_capped, keyword_init: true) do
5
+ # True when the backend stopped counting at its cap, so total_count is
6
+ # a floor ("10,000+") rather than the whole set. Views must say so —
7
+ # a capped number rendered plain reads as an exact count.
8
+ def count_capped?
9
+ !!count_capped
10
+ end
11
+
5
12
  def total_pages
6
13
  return 0 if total_count.zero? || per_page.zero?
7
14
 
@@ -1,5 +1,5 @@
1
1
  module Rails
2
2
  module Contact
3
- VERSION = "0.1.17"
3
+ VERSION = "0.1.19"
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
@@ -6,6 +6,11 @@ RSpec.describe Rails::Contact::ApplicationHelper, type: :helper do
6
6
  expect(helper.contact_initials(contact)).to eq("AS")
7
7
  end
8
8
 
9
+ it "renders an exact count plainly and a capped one as a floor" do
10
+ expect(helper.contact_count_label(1_005)).to eq("1,005")
11
+ expect(helper.contact_count_label(10_000, true)).to eq("10,000+")
12
+ end
13
+
9
14
  it "returns fallback chip for blank values" do
10
15
  expect(helper.contact_chip(nil)).to eq("-")
11
16
  expect(helper.contact_chip("x")).to eq("x")
@@ -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
@@ -107,50 +129,49 @@ RSpec.describe Rails::Contact::Search::Backends::Database do
107
129
  describe "result counting" do
108
130
  let(:backend) { described_class.new }
109
131
 
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)
132
+ def result(filters = {}, per_page: 25)
133
+ backend.search("", filters, page: 1, per_page: per_page)
113
134
  end
114
135
 
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)
136
+ it "counts a filtered scope exactly" do
137
+ # The production bug: a sheet holding one contact reported four figures
138
+ # because the count was a planner estimate, not a count.
139
+ expect(result({ "csv_import_id" => [ "imp_1" ] }).total_count).to eq(1)
140
+ end
120
141
 
121
- expect(backend.search("", {}, page: 1, per_page: 25).total_count).to eq(50_000)
122
- end
142
+ it "counts the unfiltered scope exactly" do
143
+ expect(result.total_count).to eq(Rails::Contact::Contact.count)
144
+ end
123
145
 
124
- it "counts exactly below the threshold" do
125
- allow(backend).to receive(:planner_estimate).and_return(5)
146
+ it "reports pages from the real count" do
147
+ expect(result({}, per_page: 2).total_pages).to eq(2)
148
+ end
126
149
 
127
- expect(backend.search("", {}, page: 1, per_page: 25).total_count)
128
- .to eq(Rails::Contact::Contact.count)
129
- end
150
+ it "counts the same under a metadata sort" do
151
+ # ORDER BY is stripped inside the count subquery so the LIMIT can
152
+ # short-circuit; stripping it must not change the answer.
153
+ config = Rails::Contact.configuration
154
+ original = config.metadata_sorts
155
+ config.metadata_sorts = { "score" => { key: "score" } }
130
156
 
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
157
+ expect(result({ "sort" => "score" }).total_count).to eq(3)
158
+ ensure
159
+ config.metadata_sorts = original
137
160
  end
138
161
 
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}}]')
162
+ context "when more rows match than the cap" do
163
+ before { stub_const("#{described_class}::MAX_EXACT_COUNT", 2) }
146
164
 
147
- expect(backend.send(:planner_estimate, scope)).to eq(123_456)
165
+ it "clamps the count to the cap and flags it" do
166
+ expect(result.total_count).to eq(2)
167
+ expect(result.count_capped?).to be(true)
148
168
  end
149
169
 
150
- it "returns nil on malformed planner output" do
151
- allow(scope.klass.connection).to receive(:select_value).and_return("not json")
170
+ it "does not flag a set that exactly fills the cap" do
171
+ carol.destroy!
152
172
 
153
- expect(backend.send(:planner_estimate, scope)).to be_nil
173
+ expect(result.total_count).to eq(2)
174
+ expect(result.count_capped?).to be(false)
154
175
  end
155
176
  end
156
177
  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.17
4
+ version: 0.1.19
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kshitiz Sinha