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 +4 -4
- data/CHANGELOG.md +18 -0
- data/app/controllers/rails/contact/contacts_controller.rb +3 -0
- data/app/helpers/rails/contact/application_helper.rb +7 -0
- data/app/views/rails/contact/_index_pagination.html.erb +1 -1
- data/lib/rails/contact/search/backends/database.rb +27 -24
- data/lib/rails/contact/search/result.rb +8 -1
- data/lib/rails/contact/version.rb +1 -1
- data/spec/helpers/application_helper_spec.rb +5 -0
- data/spec/search/database_backend_spec.rb +31 -32
- 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: f986b7426ce2b99924283ea2343ef48ed1f6f4c50c6e2763ba50f03be99b9a93
|
|
4
|
+
data.tar.gz: 8f04a3f04fdb7d9bb6921efa14df565be9b82029c03c522da8ac6b111e70e3f9
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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"><%=
|
|
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:
|
|
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
|
|
69
|
-
#
|
|
70
|
-
#
|
|
71
|
-
#
|
|
72
|
-
#
|
|
73
|
-
#
|
|
74
|
-
|
|
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
|
-
|
|
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
|
-
|
|
85
|
+
[ [ total, MAX_EXACT_COUNT ].min, total > MAX_EXACT_COUNT ]
|
|
83
86
|
end
|
|
84
87
|
|
|
85
|
-
#
|
|
86
|
-
#
|
|
87
|
-
#
|
|
88
|
-
#
|
|
89
|
-
#
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
|
|
@@ -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
|
-
|
|
133
|
-
|
|
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
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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
|
-
|
|
144
|
-
|
|
142
|
+
it "counts the unfiltered scope exactly" do
|
|
143
|
+
expect(result.total_count).to eq(Rails::Contact::Contact.count)
|
|
144
|
+
end
|
|
145
145
|
|
|
146
|
-
|
|
147
|
-
|
|
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
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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
|
-
|
|
162
|
-
|
|
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
|
-
|
|
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 "
|
|
173
|
-
|
|
170
|
+
it "does not flag a set that exactly fills the cap" do
|
|
171
|
+
carol.destroy!
|
|
174
172
|
|
|
175
|
-
expect(
|
|
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
|