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 +4 -4
- data/CHANGELOG.md +32 -0
- data/app/controllers/rails/contact/contacts_controller.rb +6 -3
- 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 +37 -26
- data/lib/rails/contact/search/result.rb +8 -1
- data/lib/rails/contact/version.rb +1 -1
- data/spec/controllers/contacts_controller_spec.rb +15 -5
- data/spec/helpers/application_helper_spec.rb +5 -0
- data/spec/search/database_backend_spec.rb +53 -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,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"><%=
|
|
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
|
-
|
|
83
|
+
total = scoped.klass.unscoped.from(bounded_scope(scoped), :bounded_count).count
|
|
78
84
|
|
|
79
|
-
|
|
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
|
-
#
|
|
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
|
|
@@ -106,8 +109,16 @@ module Rails
|
|
|
106
109
|
|
|
107
110
|
def apply_filters(scope, filters)
|
|
108
111
|
scoped = scope
|
|
109
|
-
|
|
110
|
-
|
|
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
|
|
|
@@ -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
|
-
|
|
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
|
-
|
|
111
|
-
|
|
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
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
-
|
|
122
|
-
|
|
142
|
+
it "counts the unfiltered scope exactly" do
|
|
143
|
+
expect(result.total_count).to eq(Rails::Contact::Contact.count)
|
|
144
|
+
end
|
|
123
145
|
|
|
124
|
-
|
|
125
|
-
|
|
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
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
-
|
|
140
|
-
|
|
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
|
-
|
|
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 "
|
|
151
|
-
|
|
170
|
+
it "does not flag a set that exactly fills the cap" do
|
|
171
|
+
carol.destroy!
|
|
152
172
|
|
|
153
|
-
expect(
|
|
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
|