rails-contact 0.1.18 → 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: b0b84870f841f736094084fa2ae0c08805f5b54a30fa8960b95cc04f14998001
4
- data.tar.gz: 830dfc497510913b838651a00f430d36919c35fca155b54424dae053d4feba60
3
+ metadata.gz: f986b7426ce2b99924283ea2343ef48ed1f6f4c50c6e2763ba50f03be99b9a93
4
+ data.tar.gz: 8f04a3f04fdb7d9bb6921efa14df565be9b82029c03c522da8ac6b111e70e3f9
5
5
  SHA512:
6
- metadata.gz: bab63c0475d073586b14a681c7822f6185b4437e077b5bf3583fc7fa1c68f0ee581a4e7248d6498e77d72e0721c41e8105c188c56dfdeda54071573b9c4b33df
7
- data.tar.gz: d46deab8ed814b053834de5157f76ef089efdc3fb04d630a03dbf85375b8686a89f70ab706b956b57f31bdf2152d2fcb9cca6352bccdecef310dfa21b8558486
6
+ metadata.gz: 7426fa7a4faf079dba58377b115f5b612463f55c7ab66aef7f4eec65fbb8b258c84dc91c148d63bd9b76e59bc08387b8df34052f4226ee066cde9eeba1eec916
7
+ data.tar.gz: 916da6b6432f11a44c8b260964f81feaa5092b2da83d4a5c9433d25c786d4e57d19d1b6f402a1c2ddaf043a8a8dbb69ea0da7308d82016323393d398e0a7b072
data/CHANGELOG.md CHANGED
@@ -1,5 +1,23 @@
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
+
3
21
  ## 0.1.18
4
22
 
5
23
  - **The `city` filter is a multi-select.** `?city[]=Pune&city[]=Delhi` now
@@ -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
@@ -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)
78
-
79
- estimate = planner_estimate(scoped)
80
- return scoped.count if estimate.nil? || estimate < APPROX_COUNT_THRESHOLD
83
+ total = scoped.klass.unscoped.from(bounded_scope(scoped), :bounded_count).count
81
84
 
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
@@ -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.18"
3
+ VERSION = "0.1.19"
4
4
  end
5
5
  end
@@ -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")
@@ -129,50 +129,49 @@ RSpec.describe Rails::Contact::Search::Backends::Database do
129
129
  describe "result counting" do
130
130
  let(:backend) { described_class.new }
131
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)
132
+ def result(filters = {}, per_page: 25)
133
+ backend.search("", filters, page: 1, per_page: per_page)
135
134
  end
136
135
 
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)
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
142
141
 
143
- expect(backend.search("", {}, page: 1, per_page: 25).total_count).to eq(50_000)
144
- end
142
+ it "counts the unfiltered scope exactly" do
143
+ expect(result.total_count).to eq(Rails::Contact::Contact.count)
144
+ end
145
145
 
146
- it "counts exactly below the threshold" do
147
- 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
148
149
 
149
- expect(backend.search("", {}, page: 1, per_page: 25).total_count)
150
- .to eq(Rails::Contact::Contact.count)
151
- 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" } }
152
156
 
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
157
+ expect(result({ "sort" => "score" }).total_count).to eq(3)
158
+ ensure
159
+ config.metadata_sorts = original
159
160
  end
160
161
 
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}}]')
162
+ context "when more rows match than the cap" do
163
+ before { stub_const("#{described_class}::MAX_EXACT_COUNT", 2) }
168
164
 
169
- 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)
170
168
  end
171
169
 
172
- it "returns nil on malformed planner output" do
173
- 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!
174
172
 
175
- 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)
176
175
  end
177
176
  end
178
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.18
4
+ version: 0.1.19
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kshitiz Sinha