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 +4 -4
- data/CHANGELOG.md +34 -0
- data/app/controllers/rails/contact/contacts_controller.rb +3 -3
- data/lib/rails/contact/search/backends/database.rb +81 -27
- data/lib/rails/contact/version.rb +1 -1
- data/spec/controllers/contacts_controller_spec.rb +15 -5
- data/spec/search/database_backend_spec.rb +130 -0
- 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: b0b84870f841f736094084fa2ae0c08805f5b54a30fa8960b95cc04f14998001
|
|
4
|
+
data.tar.gz: 830dfc497510913b838651a00f430d36919c35fca155b54424dae053d4feba60
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
#
|
|
18
|
-
#
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
|
|
64
|
-
|
|
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"]))
|
|
@@ -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
|
|
@@ -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
|