rails-contact 0.1.13 → 0.1.16
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 +26 -0
- data/README.md +353 -105
- data/app/controllers/rails/contact/contacts_controller.rb +41 -1
- data/docs/CONFIGURATION.md +91 -0
- data/lib/rails/contact/configuration.rb +35 -1
- data/lib/rails/contact/search/backends/database.rb +147 -1
- data/lib/rails/contact/search/backends/elasticsearch.rb +8 -2
- data/lib/rails/contact/version.rb +1 -1
- data/lib/tasks/rails/contact_tasks.rake +0 -1
- data/spec/controllers/contacts_controller_spec.rb +59 -2
- data/spec/search/database_backend_spec.rb +49 -0
- data/spec/search/elasticsearch_backend_spec.rb +33 -0
- data/spec/search/metadata_filters_spec.rb +140 -0
- metadata +5 -2
- data/LICENSE.txt +0 -21
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# Configuration reference
|
|
2
|
+
|
|
3
|
+
Every setting on `Rails::Contact::Configuration`, with its default and what the engine
|
|
4
|
+
does with it. Set them in `config/initializers/rails_contact.rb`:
|
|
5
|
+
|
|
6
|
+
```ruby
|
|
7
|
+
Rails::Contact.configure do |config|
|
|
8
|
+
config.search_backend = :database
|
|
9
|
+
# ...
|
|
10
|
+
end
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
All settings have defaults, so you only need to list the ones you change.
|
|
14
|
+
|
|
15
|
+
## Settings
|
|
16
|
+
|
|
17
|
+
| Setting | Type | Default | Environment variable | Description |
|
|
18
|
+
|---|---|---|---|---|
|
|
19
|
+
| `contact_class_name` | String | `"Rails::Contact::Contact"` | — | Name of the contact model. Informational in the current version — the engine references `Rails::Contact::Contact` directly. |
|
|
20
|
+
| `elasticsearch_url` | String | `"http://127.0.0.1:9200"` | `ELASTICSEARCH_URL` | URL of the Elasticsearch cluster used by the `:elasticsearch` backend. |
|
|
21
|
+
| `search_backend` | Symbol | `:elasticsearch` | — | Search backend. `:elasticsearch` uses Elasticsearch (with a database fallback on error); any other value uses the database backend. |
|
|
22
|
+
| `default_per_page` | Integer | `25` | — | Default page size for search. Capped at 100 per request. |
|
|
23
|
+
| `inherit_host_layout` | Boolean | `true` | — | When `true`, engine pages render inside the host layout named `application`. When `false`, the engine uses its own layout and bundled JavaScript. |
|
|
24
|
+
| `google_sync_enabled` | Boolean | `false` | `RAILS_CONTACT_GOOGLE_SYNC_ENABLED` (in the generated initializer) | Master switch for Google Contacts sync. The sync controller actions and jobs no-op when `false`. |
|
|
25
|
+
| `google_sync_ui_on_index` | Boolean | `true` | — | When `true`, the index renders the gem's Google sync panel partial. Set `false` to supply your own UI. |
|
|
26
|
+
| `google_max_contacts` | Integer | `25_000` | — | Cap on the rolling-window sync. `Contact.sync_window` limits to the newest N contacts by `updated_at`. |
|
|
27
|
+
| `google_token_path` | String | `"tmp/rails_contact_google_token.json"` | `RAILS_CONTACT_GOOGLE_TOKEN_PATH` | Path to the JSON file holding the OAuth access token. `TokenStore` reads the `access_token` key from it. |
|
|
28
|
+
| `google_client_id` | String / nil | `nil` | `GOOGLE_CLIENT_ID` | OAuth client id. Host-facing: a place to keep the value for your own OAuth flow. Not consumed by the engine. |
|
|
29
|
+
| `google_client_secret` | String / nil | `nil` | `GOOGLE_CLIENT_SECRET` | OAuth client secret. Host-facing, not consumed by the engine. |
|
|
30
|
+
| `google_redirect_uri` | String / nil | `nil` | `GOOGLE_REDIRECT_URI` | OAuth redirect URI. Host-facing, not consumed by the engine. |
|
|
31
|
+
| `google_contact_family_name_suffix` | String / nil | `nil` | `RAILS_CONTACT_GOOGLE_CONTACT_FAMILY_NAME_SUFFIX` | Optional suffix appended to `familyName` in Google People payloads only. Not stored on the contact. Blank disables it. |
|
|
32
|
+
| `rolling_window_sort` | Symbol | `:updated_at` | — | Intended sort column for the rolling window. Reserved: the current `sync_window` scope orders by `updated_at, id` and does not read this setting. |
|
|
33
|
+
| `reset_index_on_boot` | Boolean | `false` | — | Reserved: defined but not read by the current engine. |
|
|
34
|
+
| `metadata_filters` | Hash | `{}` | — | Declarative filters over `Contact#metadata`. Database backend only. See below. |
|
|
35
|
+
| `metadata_sorts` | Hash | `{}` | — | Declarative `?sort=` options over numeric metadata. Database backend only. See below. |
|
|
36
|
+
|
|
37
|
+
Settings marked "reserved" or "not consumed by the engine" are defined on the
|
|
38
|
+
configuration object and safe to set, but the current release does not act on them.
|
|
39
|
+
They are documented here for completeness.
|
|
40
|
+
|
|
41
|
+
## Metadata filters and sorts
|
|
42
|
+
|
|
43
|
+
Contacts carry app-specific values in the `metadata` JSON column. `metadata_filters`
|
|
44
|
+
and `metadata_sorts` let the host decide which keys are filterable and sortable. The
|
|
45
|
+
engine then permits the request params, normalizes multi-selects, guards the SQL, and
|
|
46
|
+
applies the filter. These apply to the **database backend only**; the Elasticsearch
|
|
47
|
+
backend ignores them.
|
|
48
|
+
|
|
49
|
+
```ruby
|
|
50
|
+
Rails::Contact.configure do |config|
|
|
51
|
+
config.metadata_filters = {
|
|
52
|
+
# request param name => filter declaration
|
|
53
|
+
"tier" => { key: "quality_tier", type: :values, allowed: %w[hot warm standard] },
|
|
54
|
+
"min_pax" => { key: "pax", type: :min_integer },
|
|
55
|
+
"min_score" => { key: "score", type: :min_numeric },
|
|
56
|
+
"vip" => { key: "tags", type: :tag, tag: "vip" }
|
|
57
|
+
}
|
|
58
|
+
config.metadata_sorts = {
|
|
59
|
+
"score" => { key: "score" } # ?sort=score orders by metadata score, highest first
|
|
60
|
+
}
|
|
61
|
+
end
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
`key:` is the metadata key to read. It is declared in code, never taken from a request
|
|
65
|
+
param, and must be a plain identifier matching `[a-zA-Z0-9_]+` — anything else raises,
|
|
66
|
+
as a guard against interpolating an unsafe fragment into SQL.
|
|
67
|
+
|
|
68
|
+
### Filter types
|
|
69
|
+
|
|
70
|
+
- `:values` — multi-select. Matches when `metadata->>key` equals any selected value.
|
|
71
|
+
An optional `allowed:` whitelist discards anything outside it. A scalar param (a
|
|
72
|
+
legacy bookmark such as `?tier=hot`) is coerced to a one-element selection.
|
|
73
|
+
- `:min_integer` / `:min_numeric` — numeric floor. Stored values that are not numbers
|
|
74
|
+
are filtered out rather than cast, so an imported `"TBD"` cannot raise. `:min_numeric`
|
|
75
|
+
also accepts decimals.
|
|
76
|
+
- `:tag` — checkbox. Matches when the metadata key (a JSON array) contains `tag:`. The
|
|
77
|
+
param value `"1"` switches it on.
|
|
78
|
+
- `:exclude` — hides rows whose key equals `value:`. Add `default: :on` to apply the
|
|
79
|
+
filter even when the request param is absent (first page load, bookmarks); an
|
|
80
|
+
explicit `"0"` shows everything — built for default-on "hide test data" checkboxes.
|
|
81
|
+
Rows missing the key always pass, so unclassified data is never hidden.
|
|
82
|
+
|
|
83
|
+
### Sorts
|
|
84
|
+
|
|
85
|
+
A metadata sort orders descending, puts non-numeric or missing values last, and breaks
|
|
86
|
+
ties by recency. Only sorts declared in `metadata_sorts` apply — an unknown `?sort=`
|
|
87
|
+
value is ignored.
|
|
88
|
+
|
|
89
|
+
A metadata sort is dropped while a free-text `q` search is active. The search branch
|
|
90
|
+
runs `SELECT DISTINCT`, and PostgreSQL rejects ordering by an expression that is not in
|
|
91
|
+
the select list, so search results keep recency order.
|
|
@@ -6,7 +6,8 @@ module Rails
|
|
|
6
6
|
:google_client_id, :google_client_secret, :google_redirect_uri,
|
|
7
7
|
:google_token_path, :reset_index_on_boot, :default_per_page,
|
|
8
8
|
:inherit_host_layout,
|
|
9
|
-
:google_contact_family_name_suffix
|
|
9
|
+
:google_contact_family_name_suffix,
|
|
10
|
+
:metadata_filters, :metadata_sorts
|
|
10
11
|
|
|
11
12
|
def initialize
|
|
12
13
|
@contact_class_name = "Rails::Contact::Contact"
|
|
@@ -29,6 +30,39 @@ module Rails
|
|
|
29
30
|
# importmap/Turbo match the rest of the app. Engine CSS and nested-field JS are still
|
|
30
31
|
# injected from gem templates so behavior does not depend on the engine layout asset tags.
|
|
31
32
|
@inherit_host_layout = true
|
|
33
|
+
# Declarative filters over Contact#metadata, keyed by the request param
|
|
34
|
+
# name. The host app decides which metadata keys are filterable; the
|
|
35
|
+
# engine permits the params, guards the SQL, and applies the filter
|
|
36
|
+
# (database backend only). Example:
|
|
37
|
+
#
|
|
38
|
+
# config.metadata_filters = {
|
|
39
|
+
# "tier" => { key: "quality_tier", type: :values, allowed: %w[hot warm standard] },
|
|
40
|
+
# "min_pax" => { key: "pax", type: :min_integer },
|
|
41
|
+
# "min_score" => { key: "score", type: :min_numeric },
|
|
42
|
+
# "vip" => { key: "tags", type: :tag, tag: "vip" }
|
|
43
|
+
# }
|
|
44
|
+
#
|
|
45
|
+
# :values — multi-select; matches metadata->>key IN (...). Optional
|
|
46
|
+
# :allowed whitelist discards anything else.
|
|
47
|
+
# :min_integer — numeric floor over an integer-ish metadata string;
|
|
48
|
+
# non-numeric stored values are filtered out, never cast.
|
|
49
|
+
# :min_numeric — same, but accepts decimals.
|
|
50
|
+
# :tag — checkbox; matches when the metadata key (a JSON array)
|
|
51
|
+
# contains :tag. Param value "1" switches it on.
|
|
52
|
+
# :exclude — hides rows whose key equals :value. Add default: :on
|
|
53
|
+
# to apply it even when the param is absent; an
|
|
54
|
+
# explicit "0" shows everything. Rows missing the key
|
|
55
|
+
# always pass.
|
|
56
|
+
@metadata_filters = {}
|
|
57
|
+
# Sort options over numeric metadata, keyed by the ?sort= param value:
|
|
58
|
+
#
|
|
59
|
+
# config.metadata_sorts = { "score" => { key: "score" } }
|
|
60
|
+
#
|
|
61
|
+
# Sorts descending, non-numeric values last, ties broken by recency.
|
|
62
|
+
# Ignored while a free-text q search is active: the search branch runs
|
|
63
|
+
# SELECT DISTINCT and PostgreSQL rejects ordering by an expression that
|
|
64
|
+
# is not in the select list, so search results keep recency order.
|
|
65
|
+
@metadata_sorts = {}
|
|
32
66
|
end
|
|
33
67
|
end
|
|
34
68
|
end
|
|
@@ -3,7 +3,22 @@ module Rails
|
|
|
3
3
|
module Search
|
|
4
4
|
module Backends
|
|
5
5
|
class Database
|
|
6
|
+
# Cap user input so the LIKE pattern stays bounded; 200 chars covers
|
|
7
|
+
# any realistic name/email/phone substring.
|
|
8
|
+
MAX_QUERY_LENGTH = 200
|
|
9
|
+
|
|
10
|
+
# Configured metadata keys are interpolated into SQL fragments, so
|
|
11
|
+
# they must be plain identifiers. This is a foot-gun guard for host
|
|
12
|
+
# developers, not a user-input path — params never reach the key.
|
|
13
|
+
METADATA_KEY_FORMAT = /\A[a-zA-Z0-9_]+\z/
|
|
14
|
+
|
|
6
15
|
def search(query, filters, page:, per_page:)
|
|
16
|
+
query = sanitize_query(query)
|
|
17
|
+
# Metadata sort cannot combine with free-text search: the search
|
|
18
|
+
# branch below runs SELECT DISTINCT, and PostgreSQL rejects ORDER BY
|
|
19
|
+
# expressions that are not in the select list. Searching means
|
|
20
|
+
# hunting a specific contact anyway, so q results keep recency order.
|
|
21
|
+
filters = filters.except("sort") if query.present?
|
|
7
22
|
offset = (page - 1) * per_page
|
|
8
23
|
scope = Contact.includes(:emails, :phones, :labels).recent_first
|
|
9
24
|
scope = apply_filters(scope, filters)
|
|
@@ -33,6 +48,16 @@ module Rails
|
|
|
33
48
|
|
|
34
49
|
private
|
|
35
50
|
|
|
51
|
+
# Escape LIKE metacharacters (% _ \) so a user typing "%" can't widen
|
|
52
|
+
# the match to every row, and cap length to keep the pattern bounded.
|
|
53
|
+
# The backend builds raw "%…%" LIKE patterns, so this guard belongs
|
|
54
|
+
# here — host apps should not have to wrap search() to stay safe.
|
|
55
|
+
def sanitize_query(query)
|
|
56
|
+
return query if query.blank?
|
|
57
|
+
|
|
58
|
+
ActiveRecord::Base.sanitize_sql_like(query.to_s[0, MAX_QUERY_LENGTH])
|
|
59
|
+
end
|
|
60
|
+
|
|
36
61
|
def apply_filters(scope, filters)
|
|
37
62
|
scoped = scope
|
|
38
63
|
scoped = scoped.where(current_city: filters["city"]) if filters["city"].present?
|
|
@@ -58,12 +83,133 @@ module Rails
|
|
|
58
83
|
scoped = scoped.where("metadata->>'contact_created_at' <= ?", filters["contact_created_at_end"])
|
|
59
84
|
end
|
|
60
85
|
|
|
86
|
+
# csv_import_id is a multi-select filter: it may be a single id or an
|
|
87
|
+
# array of ids. region above is already array-safe via where(region_name:),
|
|
88
|
+
# but this JSON-extraction predicate needs an explicit IN. A single id
|
|
89
|
+
# yields IN ('5'), identical to the old = '5'.
|
|
61
90
|
if filters["csv_import_id"].present?
|
|
62
|
-
|
|
91
|
+
ids = Array(filters["csv_import_id"]).map(&:to_s).reject(&:blank?)
|
|
92
|
+
scoped = scoped.where("metadata->>'csv_import_id' IN (?)", ids) if ids.any?
|
|
63
93
|
end
|
|
64
94
|
|
|
95
|
+
scoped = apply_metadata_filters(scoped, filters)
|
|
96
|
+
apply_metadata_sort(scoped, filters["sort"])
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Host-configured filters over Contact#metadata — see
|
|
100
|
+
# Configuration#metadata_filters for the declaration format. Every
|
|
101
|
+
# stored value came from user data (CSV imports, forms), so numeric
|
|
102
|
+
# comparisons never cast blindly: non-numeric values are filtered
|
|
103
|
+
# out by a guard instead of raising.
|
|
104
|
+
def apply_metadata_filters(scoped, filters)
|
|
105
|
+
Rails::Contact.configuration.metadata_filters.each do |param, config|
|
|
106
|
+
value = filters[param.to_s]
|
|
107
|
+
# A blank param means "not filtering" — except for default-on
|
|
108
|
+
# filters (config default: :on), which apply until the user
|
|
109
|
+
# explicitly switches them off with a false-y value ("0").
|
|
110
|
+
next if value.blank? && config[:default] != :on
|
|
111
|
+
|
|
112
|
+
key = metadata_key!(config.fetch(:key))
|
|
113
|
+
scoped = case config.fetch(:type)
|
|
114
|
+
when :values then apply_values_filter(scoped, key, value, config[:allowed])
|
|
115
|
+
when :min_integer then apply_min_filter(scoped, key, value, decimals: false)
|
|
116
|
+
when :min_numeric then apply_min_filter(scoped, key, value, decimals: true)
|
|
117
|
+
when :tag then apply_tag_filter(scoped, key, value, config.fetch(:tag))
|
|
118
|
+
when :exclude then apply_exclude_filter(scoped, key, value, config.fetch(:value))
|
|
119
|
+
else
|
|
120
|
+
raise ArgumentError, "unknown metadata filter type #{config[:type].inspect} for #{param.inspect}"
|
|
121
|
+
end
|
|
122
|
+
end
|
|
65
123
|
scoped
|
|
66
124
|
end
|
|
125
|
+
|
|
126
|
+
def apply_values_filter(scoped, key, value, allowed)
|
|
127
|
+
values = Array(value).map(&:to_s).reject(&:blank?)
|
|
128
|
+
values &= allowed.map(&:to_s) if allowed
|
|
129
|
+
return scoped if values.empty?
|
|
130
|
+
|
|
131
|
+
scoped.where("metadata->>'#{key}' IN (?)", values)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def apply_min_filter(scoped, key, value, decimals:)
|
|
135
|
+
floor = decimals ? value.to_f : value.to_i
|
|
136
|
+
return scoped unless floor.positive?
|
|
137
|
+
|
|
138
|
+
if postgres?(scoped)
|
|
139
|
+
# {0,1} instead of the ? quantifier: Rails' bind sanitizer counts
|
|
140
|
+
# every literal ? in the fragment as a placeholder, even inside a
|
|
141
|
+
# quoted regex.
|
|
142
|
+
pattern = decimals ? "^[0-9]+(\\.[0-9]+){0,1}$" : "^[0-9]+$"
|
|
143
|
+
cast = decimals ? "numeric" : "int"
|
|
144
|
+
scoped.where("metadata->>'#{key}' ~ '#{pattern}' AND (metadata->>'#{key}')::#{cast} >= ?", floor)
|
|
145
|
+
else
|
|
146
|
+
# SQLite (test harness): CAST never raises — junk casts to 0,
|
|
147
|
+
# which a positive floor excludes on its own.
|
|
148
|
+
scoped.where("CAST(metadata->>'#{key}' AS REAL) >= ?", floor)
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def apply_tag_filter(scoped, key, value, tag)
|
|
153
|
+
return scoped unless ActiveModel::Type::Boolean.new.cast(value)
|
|
154
|
+
|
|
155
|
+
if postgres?(scoped)
|
|
156
|
+
scoped.where("metadata->'#{key}' @> ?", [ tag ].to_json)
|
|
157
|
+
else
|
|
158
|
+
scoped.where(
|
|
159
|
+
"EXISTS (SELECT 1 FROM json_each(rails_contact_contacts.metadata, '$.#{key}') WHERE json_each.value = ?)",
|
|
160
|
+
tag
|
|
161
|
+
)
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Hide rows whose metadata key equals the configured value. Built
|
|
166
|
+
# for default-on filters (\"hide test data\"): a nil param arrives
|
|
167
|
+
# here only when the config says default: :on, and nil casts to
|
|
168
|
+
# nil (not false), so the exclusion applies; an explicit "0"
|
|
169
|
+
# switches it off. Rows MISSING the key must pass — unclassified
|
|
170
|
+
# legacy data is not test data.
|
|
171
|
+
def apply_exclude_filter(scoped, key, value, excluded)
|
|
172
|
+
return scoped if value.present? && ActiveModel::Type::Boolean.new.cast(value) == false
|
|
173
|
+
|
|
174
|
+
if postgres?(scoped)
|
|
175
|
+
scoped.where("metadata->>'#{key}' IS DISTINCT FROM ?", excluded.to_s)
|
|
176
|
+
else
|
|
177
|
+
# SQLite (test harness): IS DISTINCT FROM needs 3.39+.
|
|
178
|
+
scoped.where("COALESCE(metadata->>'#{key}', '') <> ?", excluded.to_s)
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# Descending sort over a numeric metadata key; rows with a
|
|
183
|
+
# non-numeric or missing value sink to the bottom, recency breaks
|
|
184
|
+
# ties. Only sorts declared in Configuration#metadata_sorts apply —
|
|
185
|
+
# an unknown ?sort= value is ignored.
|
|
186
|
+
def apply_metadata_sort(scoped, sort_param)
|
|
187
|
+
sort = Rails::Contact.configuration.metadata_sorts[sort_param.to_s]
|
|
188
|
+
return scoped unless sort
|
|
189
|
+
|
|
190
|
+
key = metadata_key!(sort.fetch(:key))
|
|
191
|
+
order = if postgres?(scoped)
|
|
192
|
+
"CASE WHEN metadata->>'#{key}' ~ '^[0-9]+(\\.[0-9]+){0,1}$' " \
|
|
193
|
+
"THEN (metadata->>'#{key}')::numeric ELSE -1 END DESC, " \
|
|
194
|
+
"rails_contact_contacts.created_at DESC"
|
|
195
|
+
else
|
|
196
|
+
"CAST(metadata->>'#{key}' AS REAL) DESC, rails_contact_contacts.created_at DESC"
|
|
197
|
+
end
|
|
198
|
+
scoped.reorder(Arel.sql(order))
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def metadata_key!(key)
|
|
202
|
+
key = key.to_s
|
|
203
|
+
unless METADATA_KEY_FORMAT.match?(key)
|
|
204
|
+
raise ArgumentError, "metadata filter key #{key.inspect} must match #{METADATA_KEY_FORMAT.inspect}"
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
key
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def postgres?(scoped)
|
|
211
|
+
scoped.klass.connection.adapter_name.match?(/postgres/i)
|
|
212
|
+
end
|
|
67
213
|
end
|
|
68
214
|
end
|
|
69
215
|
end
|
|
@@ -120,8 +120,8 @@ module Rails
|
|
|
120
120
|
|
|
121
121
|
def filter_clauses(filters)
|
|
122
122
|
clauses = []
|
|
123
|
-
clauses <<
|
|
124
|
-
clauses <<
|
|
123
|
+
clauses << match_clause(:current_city, filters["city"]) if filters["city"].present?
|
|
124
|
+
clauses << match_clause(:region_name, filters["region"]) if filters["region"].present?
|
|
125
125
|
unless filters["starred"].nil?
|
|
126
126
|
starred_value = ActiveModel::Type::Boolean.new.cast(filters["starred"])
|
|
127
127
|
clauses << { term: { starred: starred_value } }
|
|
@@ -133,6 +133,12 @@ module Rails
|
|
|
133
133
|
clauses
|
|
134
134
|
end
|
|
135
135
|
|
|
136
|
+
# city and region are multi-select filters: an array uses a `terms`
|
|
137
|
+
# clause (match any), a single value keeps the scalar `term` clause.
|
|
138
|
+
def match_clause(field, value)
|
|
139
|
+
value.is_a?(Array) ? { terms: { field => value } } : { term: { field => value } }
|
|
140
|
+
end
|
|
141
|
+
|
|
136
142
|
def document_for(contact)
|
|
137
143
|
{
|
|
138
144
|
id: contact.id,
|
|
@@ -4,10 +4,67 @@ 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/
|
|
7
|
+
it "permits city/sync_eligible and coerces a scalar region to an array" do
|
|
8
8
|
controller.params = ActionController::Parameters.new(city: "Delhi", region: "Europe", sync_eligible: "true", x: "1")
|
|
9
9
|
permitted = controller.send(:filter_params)
|
|
10
|
-
expect(permitted.to_h).to eq({ "city" => "Delhi", "region" => "Europe", "sync_eligible" => "true" })
|
|
10
|
+
expect(permitted.to_h).to eq({ "city" => "Delhi", "region" => [ "Europe" ], "sync_eligible" => "true" })
|
|
11
|
+
end
|
|
12
|
+
|
|
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
|
+
permitted = controller.send(:filter_params)
|
|
16
|
+
expect(permitted.to_h).to eq({ "region" => [ "Europe", "Asia" ], "csv_import_id" => [ "5", "7" ] })
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
it "strips the hidden blank a <select multiple> submits" do
|
|
20
|
+
controller.params = ActionController::Parameters.new(csv_import_id: [ "", "7" ])
|
|
21
|
+
permitted = controller.send(:filter_params)
|
|
22
|
+
expect(permitted.to_h).to eq({ "csv_import_id" => [ "7" ] })
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
it "drops a multi-select key entirely when only blanks are submitted" do
|
|
26
|
+
controller.params = ActionController::Parameters.new(region: [ "" ])
|
|
27
|
+
permitted = controller.send(:filter_params)
|
|
28
|
+
expect(permitted.to_h).to eq({})
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
describe "configured metadata filter params" do
|
|
33
|
+
around do |example|
|
|
34
|
+
config = Rails::Contact.configuration
|
|
35
|
+
original_filters = config.metadata_filters
|
|
36
|
+
original_sorts = config.metadata_sorts
|
|
37
|
+
config.metadata_filters = {
|
|
38
|
+
"tier" => { key: "quality_tier", type: :values, allowed: %w[hot warm] },
|
|
39
|
+
"min_pax" => { key: "pax", type: :min_integer }
|
|
40
|
+
}
|
|
41
|
+
config.metadata_sorts = { "score" => { key: "score" } }
|
|
42
|
+
example.run
|
|
43
|
+
ensure
|
|
44
|
+
config.metadata_filters = original_filters
|
|
45
|
+
config.metadata_sorts = original_sorts
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
it "permits configured array, scalar and sort params" do
|
|
49
|
+
controller.params = ActionController::Parameters.new(
|
|
50
|
+
tier: [ "hot" ], min_pax: "4", sort: "score", unrelated: "x"
|
|
51
|
+
)
|
|
52
|
+
permitted = controller.send(:filter_params)
|
|
53
|
+
expect(permitted.to_h).to eq({ "tier" => [ "hot" ], "min_pax" => "4", "sort" => "score" })
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
it "normalizes configured multi-selects like region (scalar coercion + blank strip)" do
|
|
57
|
+
controller.params = ActionController::Parameters.new(tier: "warm")
|
|
58
|
+
expect(controller.send(:filter_params).to_h).to eq({ "tier" => [ "warm" ] })
|
|
59
|
+
|
|
60
|
+
controller.params = ActionController::Parameters.new(tier: [ "" ])
|
|
61
|
+
expect(controller.send(:filter_params).to_h).to eq({})
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
it "does not permit sort when no metadata sorts are configured" do
|
|
65
|
+
Rails::Contact.configuration.metadata_sorts = {}
|
|
66
|
+
controller.params = ActionController::Parameters.new(sort: "score")
|
|
67
|
+
expect(controller.send(:filter_params).to_h).to eq({})
|
|
11
68
|
end
|
|
12
69
|
end
|
|
13
70
|
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
require "rails_helper"
|
|
2
|
+
|
|
3
|
+
RSpec.describe Rails::Contact::Search::Backends::Database do
|
|
4
|
+
# metadata is a text column read back through JSON.parse, so store JSON for
|
|
5
|
+
# the SQL `metadata->>'csv_import_id'` extraction to resolve.
|
|
6
|
+
def make(name, import_id)
|
|
7
|
+
create(:rails_contact_contact, given_name: name, metadata: { "csv_import_id" => import_id }.to_json)
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
let!(:alice) { make("Alice", "imp_1") }
|
|
11
|
+
let!(:bob) { make("Bob", "imp_2") }
|
|
12
|
+
let!(:carol) { make("Carol", "imp_3") }
|
|
13
|
+
|
|
14
|
+
def records(filters)
|
|
15
|
+
described_class.new.search("", filters, page: 1, per_page: 25).records
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
describe "csv_import_id filter (multi-select)" do
|
|
19
|
+
it "matches contacts across every selected import (array -> IN)" do
|
|
20
|
+
expect(records("csv_import_id" => [ "imp_1", "imp_2" ])).to match_array([ alice, bob ])
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
it "matches a single import exactly as before (scalar)" do
|
|
24
|
+
expect(records("csv_import_id" => "imp_1")).to match_array([ alice ])
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
it "applies no constraint when the selection is blank only" do
|
|
28
|
+
expect(records("csv_import_id" => [ "" ])).to match_array([ alice, bob, carol ])
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
describe "query sanitization" do
|
|
33
|
+
def search_for(query)
|
|
34
|
+
described_class.new.search(query, {}, page: 1, per_page: 25).records
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
it "treats % as a literal, not a match-everything wildcard" do
|
|
38
|
+
expect(search_for("%")).to be_empty
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
it "still matches a real substring" do
|
|
42
|
+
expect(search_for("Ali")).to include(alice)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
it "does not blow up on pathologically long input" do
|
|
46
|
+
expect { search_for("a" * 1000) }.not_to raise_error
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
require "rails_helper"
|
|
2
|
+
|
|
3
|
+
RSpec.describe Rails::Contact::Search::Backends::Elasticsearch do
|
|
4
|
+
# Captures the query body so we can assert filter-clause shape without a real
|
|
5
|
+
# Elasticsearch server. Returns an empty hit set so #search resolves cleanly.
|
|
6
|
+
let(:captured) { {} }
|
|
7
|
+
let(:client) do
|
|
8
|
+
cap = captured
|
|
9
|
+
Class.new do
|
|
10
|
+
define_method(:initialize) { cap }
|
|
11
|
+
define_method(:search) do |index:, body:|
|
|
12
|
+
cap[:index] = index
|
|
13
|
+
cap[:body] = body
|
|
14
|
+
{ "hits" => { "total" => { "value" => 0 }, "hits" => [] } }
|
|
15
|
+
end
|
|
16
|
+
end.new
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def filter_clauses(filters)
|
|
20
|
+
described_class.new(client: client).search("", filters, page: 1, per_page: 25)
|
|
21
|
+
captured[:body][:query][:bool][:filter]
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
it "emits a terms clause for a multi-select region array" do
|
|
25
|
+
expect(filter_clauses("region" => [ "Europe", "Asia" ]))
|
|
26
|
+
.to include({ terms: { region_name: [ "Europe", "Asia" ] } })
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
it "emits a scalar term clause for a single city" do
|
|
30
|
+
expect(filter_clauses("city" => "Delhi"))
|
|
31
|
+
.to include({ term: { current_city: "Delhi" } })
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
require "rails_helper"
|
|
2
|
+
|
|
3
|
+
# Host-configured metadata filters + sorts (Configuration#metadata_filters /
|
|
4
|
+
# #metadata_sorts) applied by the database backend. The suite runs on SQLite;
|
|
5
|
+
# the PostgreSQL branches of the SQL are exercised by host applications.
|
|
6
|
+
RSpec.describe Rails::Contact::Search::Backends::Database do
|
|
7
|
+
around do |example|
|
|
8
|
+
config = Rails::Contact.configuration
|
|
9
|
+
original_filters = config.metadata_filters
|
|
10
|
+
original_sorts = config.metadata_sorts
|
|
11
|
+
config.metadata_filters = {
|
|
12
|
+
"tier" => { key: "quality_tier", type: :values, allowed: %w[hot warm standard] },
|
|
13
|
+
"window" => { key: "booking_window", type: :values },
|
|
14
|
+
"min_pax" => { key: "pax", type: :min_integer },
|
|
15
|
+
"min_score" => { key: "score", type: :min_numeric },
|
|
16
|
+
"vip" => { key: "tags", type: :tag, tag: "vip" },
|
|
17
|
+
"hide_junk" => { key: "authenticity", type: :exclude, value: "junk", default: :on }
|
|
18
|
+
}
|
|
19
|
+
config.metadata_sorts = { "score" => { key: "score" } }
|
|
20
|
+
example.run
|
|
21
|
+
ensure
|
|
22
|
+
config.metadata_filters = original_filters
|
|
23
|
+
config.metadata_sorts = original_sorts
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def make(name, meta)
|
|
27
|
+
create(:rails_contact_contact, given_name: name, metadata: meta.to_json)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
let!(:hot) do
|
|
31
|
+
make("Hot", "quality_tier" => "hot", "pax" => "12", "score" => "18.6",
|
|
32
|
+
"booking_window" => "This week", "tags" => [ "vip" ])
|
|
33
|
+
end
|
|
34
|
+
let!(:warm) { make("Warm", "quality_tier" => "warm", "pax" => "2", "score" => "9.1") }
|
|
35
|
+
let!(:junk) { make("Junk", "quality_tier" => "standard", "pax" => "TBD", "score" => "n/a") }
|
|
36
|
+
|
|
37
|
+
def records(filters)
|
|
38
|
+
described_class.new.search("", filters, page: 1, per_page: 25).records
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
describe ":values filters" do
|
|
42
|
+
it "matches any of the selected values" do
|
|
43
|
+
expect(records("tier" => [ "hot", "warm" ])).to match_array([ hot, warm ])
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
it "coerces a scalar like the other multi-selects" do
|
|
47
|
+
expect(records("tier" => "hot")).to eq([ hot ])
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
it "discards values outside the whitelist" do
|
|
51
|
+
expect(records("tier" => [ "hot", "'; DROP TABLE--" ])).to eq([ hot ])
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
it "applies no constraint when only junk was submitted" do
|
|
55
|
+
expect(records("tier" => [ "'; DROP TABLE--" ])).to match_array([ hot, warm, junk ])
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
it "matches free values when no whitelist is configured" do
|
|
59
|
+
expect(records("window" => [ "This week" ])).to eq([ hot ])
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
describe ":min_integer / :min_numeric filters" do
|
|
64
|
+
it "keeps rows at or above the floor and drops non-numeric values" do
|
|
65
|
+
expect(records("min_pax" => "3")).to eq([ hot ])
|
|
66
|
+
expect(records("min_pax" => "2")).to match_array([ hot, warm ])
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
it "accepts decimal floors" do
|
|
70
|
+
expect(records("min_score" => "9.1")).to match_array([ hot, warm ])
|
|
71
|
+
expect(records("min_score" => "10")).to eq([ hot ])
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
it "ignores a non-positive floor" do
|
|
75
|
+
expect(records("min_pax" => "0")).to match_array([ hot, warm, junk ])
|
|
76
|
+
expect(records("min_pax" => "abc")).to match_array([ hot, warm, junk ])
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
describe ":tag filter" do
|
|
81
|
+
it "matches contacts whose tag array contains the configured tag" do
|
|
82
|
+
expect(records("vip" => "1")).to eq([ hot ])
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
it "is inert when the checkbox is off" do
|
|
86
|
+
expect(records("vip" => "0")).to match_array([ hot, warm, junk ])
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
describe ":exclude filter (default on)" do
|
|
91
|
+
let!(:junk) { make("Junk2", "authenticity" => "junk") }
|
|
92
|
+
|
|
93
|
+
it "hides the excluded value when the param is ABSENT (default on)" do
|
|
94
|
+
expect(records({})).not_to include(junk)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
it "hides when the param is affirmative" do
|
|
98
|
+
expect(records("hide_junk" => "1")).not_to include(junk)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
it "shows everything when explicitly disabled with 0" do
|
|
102
|
+
expect(records("hide_junk" => "0")).to include(junk)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
it "never hides rows that simply lack the key (unclassified data)" do
|
|
106
|
+
expect(records({})).to include(hot, warm)
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
describe "metadata sort" do
|
|
111
|
+
it "orders by the metadata number descending, junk last" do
|
|
112
|
+
expect(records("sort" => "score")).to eq([ hot, warm, junk ])
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
it "ignores an unknown sort value" do
|
|
116
|
+
expect { records("sort" => "bogus") }.not_to raise_error
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
it "drops the sort under a free-text query instead of erroring (DISTINCT branch)" do
|
|
120
|
+
result = described_class.new.search("Stone", { "sort" => "score" }, page: 1, per_page: 25)
|
|
121
|
+
expect(result.records).to match_array([ hot, warm, junk ]) # factory family_name
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
describe "key validation" do
|
|
126
|
+
it "rejects a config key that is not a plain identifier" do
|
|
127
|
+
Rails::Contact.configuration.metadata_filters = {
|
|
128
|
+
"bad" => { key: "x'; DROP", type: :values }
|
|
129
|
+
}
|
|
130
|
+
expect { records("bad" => [ "1" ]) }.to raise_error(ArgumentError, /must match/)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
it "rejects an unknown filter type" do
|
|
134
|
+
Rails::Contact.configuration.metadata_filters = {
|
|
135
|
+
"bad" => { key: "x", type: :wat }
|
|
136
|
+
}
|
|
137
|
+
expect { records("bad" => "1") }.to raise_error(ArgumentError, /unknown metadata filter type/)
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|