forest_admin_datasource_intercom 1.49.0
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 +7 -0
- data/.rspec +3 -0
- data/LICENSE +202 -0
- data/README.md +811 -0
- data/Rakefile +6 -0
- data/exe/forest_admin_intercom_probe +287 -0
- data/forest_admin_datasource_intercom.gemspec +38 -0
- data/lib/forest_admin_datasource_intercom/cache.rb +84 -0
- data/lib/forest_admin_datasource_intercom/client.rb +546 -0
- data/lib/forest_admin_datasource_intercom/collections/admin.rb +83 -0
- data/lib/forest_admin_datasource_intercom/collections/base_collection.rb +161 -0
- data/lib/forest_admin_datasource_intercom/collections/company/serializer.rb +44 -0
- data/lib/forest_admin_datasource_intercom/collections/company.rb +69 -0
- data/lib/forest_admin_datasource_intercom/collections/contact/serializer.rb +106 -0
- data/lib/forest_admin_datasource_intercom/collections/contact.rb +266 -0
- data/lib/forest_admin_datasource_intercom/collections/contact_identity.rb +77 -0
- data/lib/forest_admin_datasource_intercom/collections/conversation/serializer.rb +75 -0
- data/lib/forest_admin_datasource_intercom/collections/conversation/timeline.rb +40 -0
- data/lib/forest_admin_datasource_intercom/collections/conversation.rb +153 -0
- data/lib/forest_admin_datasource_intercom/collections/cursor_collection.rb +363 -0
- data/lib/forest_admin_datasource_intercom/collections/custom_attributes.rb +55 -0
- data/lib/forest_admin_datasource_intercom/collections/fetch_all_collection.rb +196 -0
- data/lib/forest_admin_datasource_intercom/collections/offset_collection.rb +311 -0
- data/lib/forest_admin_datasource_intercom/collections/records_by_id.rb +97 -0
- data/lib/forest_admin_datasource_intercom/collections/relations.rb +294 -0
- data/lib/forest_admin_datasource_intercom/collections/team.rb +70 -0
- data/lib/forest_admin_datasource_intercom/collections/team_membership.rb +51 -0
- data/lib/forest_admin_datasource_intercom/collections/ticket/derived_columns.rb +130 -0
- data/lib/forest_admin_datasource_intercom/collections/ticket/serializer.rb +70 -0
- data/lib/forest_admin_datasource_intercom/collections/ticket.rb +143 -0
- data/lib/forest_admin_datasource_intercom/collections/ticket_state.rb +40 -0
- data/lib/forest_admin_datasource_intercom/collections/ticket_type.rb +46 -0
- data/lib/forest_admin_datasource_intercom/collections/timeline.rb +40 -0
- data/lib/forest_admin_datasource_intercom/configuration.rb +226 -0
- data/lib/forest_admin_datasource_intercom/datasource.rb +78 -0
- data/lib/forest_admin_datasource_intercom/pagination/cursor_walker.rb +123 -0
- data/lib/forest_admin_datasource_intercom/query/caller_zone.rb +48 -0
- data/lib/forest_admin_datasource_intercom/query/condition_tree_translator.rb +169 -0
- data/lib/forest_admin_datasource_intercom/query/day_bounds.rb +75 -0
- data/lib/forest_admin_datasource_intercom/query/filter_value.rb +177 -0
- data/lib/forest_admin_datasource_intercom/query/operator_table.rb +54 -0
- data/lib/forest_admin_datasource_intercom/query/search_fields.rb +225 -0
- data/lib/forest_admin_datasource_intercom/query/search_fields.yml +663 -0
- data/lib/forest_admin_datasource_intercom/rate_limiter.rb +148 -0
- data/lib/forest_admin_datasource_intercom/retry_policy.rb +95 -0
- data/lib/forest_admin_datasource_intercom/schema/attribute_naming.rb +52 -0
- data/lib/forest_admin_datasource_intercom/schema/data_attributes_introspector.rb +88 -0
- data/lib/forest_admin_datasource_intercom/schema/ticket_attributes_introspector.rb +133 -0
- data/lib/forest_admin_datasource_intercom/throttle.rb +19 -0
- data/lib/forest_admin_datasource_intercom/version.rb +3 -0
- data/lib/forest_admin_datasource_intercom.rb +57 -0
- metadata +180 -0
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
module ForestAdminDatasourceIntercom
|
|
2
|
+
module Collections
|
|
3
|
+
# `POST /companies/list` paginates by offset. Companies have no search endpoint
|
|
4
|
+
# (`/companies/scroll` allows one open scroll per app, expiring in a minute), so filters are
|
|
5
|
+
# limited to exact lookups and everything else is refused. Long by line count only: refusals.
|
|
6
|
+
class OffsetCollection < BaseCollection # rubocop:disable Metrics/ClassLength
|
|
7
|
+
include RecordsById
|
|
8
|
+
|
|
9
|
+
# What one page holds when the read names no window: a relation resolving
|
|
10
|
+
# its target, a segment, a customizer. A list view always names one.
|
|
11
|
+
UNBOUNDED_PAGE_SIZE = Client::MAX_PER_PAGE
|
|
12
|
+
|
|
13
|
+
# And how many such pages are read before the answer is cut short. The
|
|
14
|
+
# figure only ever applies to a read with no window of its own.
|
|
15
|
+
MAX_COLLECTED_PAGES = 10
|
|
16
|
+
|
|
17
|
+
# What a read that *does* name a window may collect before it is cut
|
|
18
|
+
# short. A window is its own bound, so the page count above must not apply
|
|
19
|
+
# to it: a window needing eleven pages would come back one page short
|
|
20
|
+
# while the warning blamed it for naming no window -- which it did name.
|
|
21
|
+
# This is the backstop for a window nothing sane asked for, and it is the
|
|
22
|
+
# cursor walker's own record budget, that tier being bounded the same way.
|
|
23
|
+
MAX_COLLECTED_RECORDS = Pagination::CursorWalker::MAX_RECORDS
|
|
24
|
+
|
|
25
|
+
# How many records of this collection one relation read may resolve.
|
|
26
|
+
# Higher than `MAX_ID_READS`, which bounds a single request batch, and
|
|
27
|
+
# deliberately above every page size a list view offers: a page naming one
|
|
28
|
+
# account per row resolves, and a read naming more -- an export, a
|
|
29
|
+
# segment resolved whole -- is refused by name rather than answered with
|
|
30
|
+
# a nil where an account exists. There is no bulk read for a company, so
|
|
31
|
+
# each one costs a request and the figure is what an operator waits for.
|
|
32
|
+
MAX_RELATION_READS = 100
|
|
33
|
+
|
|
34
|
+
# Search is declared only to be refused by name: left to the customizer,
|
|
35
|
+
# it would be emulated as equalities on columns no lookup answers.
|
|
36
|
+
def initialize(datasource, name)
|
|
37
|
+
super
|
|
38
|
+
enable_count
|
|
39
|
+
enable_search
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def ids_per_read = MAX_ID_READS
|
|
43
|
+
def max_resolvable_ids = MAX_RELATION_READS
|
|
44
|
+
|
|
45
|
+
def list(caller, filter, projection)
|
|
46
|
+
warn_ignored_sort(filter&.sort)
|
|
47
|
+
|
|
48
|
+
client.with_read_scope do
|
|
49
|
+
records = fetch_records(filter)
|
|
50
|
+
serialized = records.map { |record| serialize(record) }
|
|
51
|
+
rows = serialized.map { |record| project(record, projection) }
|
|
52
|
+
|
|
53
|
+
embed_relations(caller, serialized, rows, projection)
|
|
54
|
+
rows
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Count only, and never a group: `total_count` is exact on every listing,
|
|
59
|
+
# while grouping the page in hand would answer a fraction as if it were
|
|
60
|
+
# the whole.
|
|
61
|
+
def aggregate(_caller, filter, aggregation, _limit = nil)
|
|
62
|
+
refuse_unsupported_aggregation!(aggregation)
|
|
63
|
+
|
|
64
|
+
[{ 'group' => {}, 'value' => count_records(filter) }]
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
protected
|
|
68
|
+
|
|
69
|
+
# The endpoint that lists the collection by offset, the one that reads a
|
|
70
|
+
# record, and the lookups Intercom answers on the listing path.
|
|
71
|
+
def list_path = raise(NotImplementedError, "#{self.class} did not implement list_path")
|
|
72
|
+
def record_endpoint = raise(NotImplementedError, "#{self.class} did not implement record_endpoint")
|
|
73
|
+
def lookup_path = record_endpoint
|
|
74
|
+
def lookups = {}
|
|
75
|
+
|
|
76
|
+
def serialize(_entity) = raise(NotImplementedError, "#{self.class} did not implement serialize")
|
|
77
|
+
|
|
78
|
+
# A column advertises a filter only where Intercom looks the collection up
|
|
79
|
+
# by it, so a column cannot offer a filter this tier would then refuse.
|
|
80
|
+
# The primary key is the exception and it is not a filter: `id equals X`
|
|
81
|
+
# and `id in [...]` are answered by the record endpoint.
|
|
82
|
+
#
|
|
83
|
+
# Nothing is sortable: the listing takes no order and ordering a page in
|
|
84
|
+
# hand would order a fraction of the collection.
|
|
85
|
+
def add_column(name, type, is_primary_key: false)
|
|
86
|
+
add_field(name, ColumnSchema.new(column_type: type,
|
|
87
|
+
filter_operators: column_operators(name, is_primary_key),
|
|
88
|
+
is_primary_key: is_primary_key,
|
|
89
|
+
is_read_only: true,
|
|
90
|
+
is_sortable: false,
|
|
91
|
+
is_groupable: false))
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
private
|
|
95
|
+
|
|
96
|
+
def column_operators(name, is_primary_key)
|
|
97
|
+
return [Operators::EQUAL, Operators::IN] if is_primary_key
|
|
98
|
+
|
|
99
|
+
lookups.key?(name) ? [Operators::EQUAL] : []
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def fetch_records(filter)
|
|
103
|
+
refuse_search! unless blank_search?(filter)
|
|
104
|
+
return [] if matches_nothing?(filter)
|
|
105
|
+
|
|
106
|
+
ids = id_lookup(filter)
|
|
107
|
+
return records_by_ids(page_window(ids, filter)) if ids
|
|
108
|
+
|
|
109
|
+
lookup = lookup_condition(filter)
|
|
110
|
+
return page_window(looked_up_records(lookup), filter) if lookup
|
|
111
|
+
|
|
112
|
+
refuse_condition!(filter.condition_tree) unless filter&.condition_tree.nil?
|
|
113
|
+
|
|
114
|
+
listed_records(filter)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def count_records(filter)
|
|
118
|
+
refuse_search! unless blank_search?(filter)
|
|
119
|
+
return 0 if matches_nothing?(filter)
|
|
120
|
+
|
|
121
|
+
ids = id_lookup(filter)
|
|
122
|
+
return count_by_ids(ids) if ids
|
|
123
|
+
|
|
124
|
+
lookup = lookup_condition(filter)
|
|
125
|
+
return looked_up_count(lookup) if lookup
|
|
126
|
+
|
|
127
|
+
refuse_condition!(filter.condition_tree) unless filter&.condition_tree.nil?
|
|
128
|
+
|
|
129
|
+
exact_count(read_offset_page(page: 1, per_page: 1))
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# The window a list view asked for, read as the page Intercom counts from
|
|
133
|
+
# 1. An offset that does not fall on a page boundary is served by reading
|
|
134
|
+
# the page it lands in and the ones after it until the window is filled --
|
|
135
|
+
# exactly, rather than by rounding the offset to something the API likes.
|
|
136
|
+
def listed_records(filter)
|
|
137
|
+
offset, limit = window(filter&.page)
|
|
138
|
+
return [] if limit&.zero?
|
|
139
|
+
|
|
140
|
+
per_page = Client.bounded_per_page(limit || UNBOUNDED_PAGE_SIZE)
|
|
141
|
+
skip = offset % per_page
|
|
142
|
+
|
|
143
|
+
collected = collect_pages(first_page: (offset / per_page) + 1, per_page: per_page,
|
|
144
|
+
wanted: limit && (skip + limit))
|
|
145
|
+
|
|
146
|
+
limit ? (collected[skip, limit] || []) : collected.drop(skip)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def collect_pages(first_page:, per_page:, wanted:)
|
|
150
|
+
records = []
|
|
151
|
+
page = first_page
|
|
152
|
+
read = 0
|
|
153
|
+
|
|
154
|
+
loop do
|
|
155
|
+
answer = read_offset_page(page: page, per_page: per_page)
|
|
156
|
+
records.concat(answer.records)
|
|
157
|
+
read += 1
|
|
158
|
+
break if last_page?(answer, page) || (wanted && records.size >= wanted)
|
|
159
|
+
break if cap_reached?(read, records.size, wanted)
|
|
160
|
+
|
|
161
|
+
page += 1
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# Intercom numbers its pages by `per_page`, so the last one read cannot
|
|
165
|
+
# be asked smaller: what it brings past the budget is cut here instead.
|
|
166
|
+
wanted ? records.first(MAX_COLLECTED_RECORDS) : records
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def read_offset_page(page:, per_page:)
|
|
170
|
+
client.offset_page(list_path, page: page, per_page: per_page)
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def last_page?(answer, page)
|
|
174
|
+
answer.records.empty? || (answer.total_pages && page >= answer.total_pages)
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# Which of the two caps applies, and the reason that goes with it: a read
|
|
178
|
+
# with no window of its own is stopped by the page count, a read that
|
|
179
|
+
# named one by the record budget. Telling a list view it named no window
|
|
180
|
+
# is exactly the confusion these two keep apart.
|
|
181
|
+
def cap_reached?(read, collected, wanted)
|
|
182
|
+
if wanted
|
|
183
|
+
return false if collected < MAX_COLLECTED_RECORDS
|
|
184
|
+
|
|
185
|
+
warn_capped("#{collected} record(s)",
|
|
186
|
+
'this read named a window larger than one answer may hold')
|
|
187
|
+
else
|
|
188
|
+
return false if read < MAX_COLLECTED_PAGES
|
|
189
|
+
|
|
190
|
+
warn_capped("#{read} page(s) / #{collected} record(s)",
|
|
191
|
+
'this read named no window of its own, and a list view always does')
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
true
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def warn_capped(reached, reason)
|
|
198
|
+
ForestAdminDatasourceIntercom.logger.warn(
|
|
199
|
+
"[forest_admin_datasource_intercom] Stopped reading #{name} after #{reached}; the rest is left out: " \
|
|
200
|
+
"#{reason}."
|
|
201
|
+
)
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# A filter with no page asks for every record it matched; nil is how the
|
|
205
|
+
# window says so.
|
|
206
|
+
def window(page)
|
|
207
|
+
return [0, nil] if page.nil?
|
|
208
|
+
|
|
209
|
+
[page.offset.to_i.clamp(0, nil), page_limit(page)]
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# `Or []`, which `ConditionTreeFactory.match_ids` builds from no ids at all
|
|
213
|
+
# -- a live-query segment that returned none.
|
|
214
|
+
def matches_nothing?(filter)
|
|
215
|
+
tree = filter&.condition_tree
|
|
216
|
+
tree.is_a?(Branch) && tree.aggregator.to_s.casecmp('or').zero? && Array(tree.conditions).empty?
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def lookup_condition(filter)
|
|
220
|
+
tree = filter&.condition_tree
|
|
221
|
+
return nil unless tree.is_a?(Leaf) && tree.operator == Operators::EQUAL
|
|
222
|
+
|
|
223
|
+
parameter = lookups[tree.field.to_s]
|
|
224
|
+
parameter && { parameter => tree.value.to_s }
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
# Read as a single page; overflow is reported. Intercom answers a lookup matching nothing
|
|
228
|
+
# with a 404, read here as an empty page.
|
|
229
|
+
def looked_up_records(params)
|
|
230
|
+
answer = lookup(params)
|
|
231
|
+
return [] unless answer
|
|
232
|
+
|
|
233
|
+
warn_truncated_lookup(params) if answer.next_cursor
|
|
234
|
+
answer.records
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
# Intercom's own count when it gave one. A lookup spread over several
|
|
238
|
+
# pages without one cannot be counted from the first page alone.
|
|
239
|
+
def looked_up_count(params)
|
|
240
|
+
answer = lookup(params)
|
|
241
|
+
return 0 unless answer
|
|
242
|
+
return answer.total_count if answer.total_count
|
|
243
|
+
return exact_count(answer) if answer.next_cursor
|
|
244
|
+
|
|
245
|
+
answer.records.size
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def lookup(params)
|
|
249
|
+
client.lookup_page(lookup_path, params: params)
|
|
250
|
+
rescue APIError => e
|
|
251
|
+
raise unless e.status == 404
|
|
252
|
+
|
|
253
|
+
nil
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
def refuse_search!
|
|
257
|
+
raise UnsupportedOperatorError,
|
|
258
|
+
"#{name} cannot answer a free-text search: Intercom exposes no search endpoint for this collection " \
|
|
259
|
+
"and looks a record up by id, #{lookups.keys.join(", ")} alone, one exact value at a time. Filter " \
|
|
260
|
+
'on one of those instead of searching.'
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def refuse_condition!(tree)
|
|
264
|
+
leaves = []
|
|
265
|
+
tree.some_leaf do |leaf|
|
|
266
|
+
leaves << leaf
|
|
267
|
+
false
|
|
268
|
+
end
|
|
269
|
+
offender = leaves.find { |leaf| !published?(leaf) }
|
|
270
|
+
refused = if offender
|
|
271
|
+
"filter #{offender.field.inspect}"
|
|
272
|
+
else
|
|
273
|
+
"combine conditions on #{leaves.map(&:field).uniq.join(", ")}"
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
raise UnsupportedOperatorError,
|
|
277
|
+
"#{name} cannot #{refused}: Intercom exposes no search endpoint for this " \
|
|
278
|
+
"collection and looks a record up by #{lookups.keys.join(", ")} alone -- one exact value at a time, " \
|
|
279
|
+
'with no combination and no other operator. Filter on one of those, or reach the record from the ' \
|
|
280
|
+
'collection next door.'
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
def published?(leaf)
|
|
284
|
+
schema = fields[leaf.field]
|
|
285
|
+
schema.respond_to?(:filter_operators) && schema.filter_operators.include?(leaf.operator)
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
# Intercom takes no order on this listing at all -- there is no parameter
|
|
289
|
+
# for one -- so an order asked for and not applied is reported here or
|
|
290
|
+
# nowhere. The ascending primary-key sort the agent injects when a request
|
|
291
|
+
# names none is not one of those.
|
|
292
|
+
def warn_ignored_sort(sort)
|
|
293
|
+
clauses = Array(sort)
|
|
294
|
+
return if clauses.empty? || default_pk_sort?(clauses)
|
|
295
|
+
|
|
296
|
+
ForestAdminDatasourceIntercom.logger.warn(
|
|
297
|
+
"[forest_admin_datasource_intercom] #{name} was asked to sort on " \
|
|
298
|
+
"#{clauses.map { |clause| sort_field(clause) }.join(", ")}, and Intercom takes no order " \
|
|
299
|
+
'on this listing. The rows come back in the order the API imposes.'
|
|
300
|
+
)
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
def warn_truncated_lookup(params)
|
|
304
|
+
ForestAdminDatasourceIntercom.logger.warn(
|
|
305
|
+
"[forest_admin_datasource_intercom] #{name} looked up #{params.inspect} and Intercom advertised more " \
|
|
306
|
+
'records than one page holds; the rest is left out. This lookup is meant for a key that names one record.'
|
|
307
|
+
)
|
|
308
|
+
end
|
|
309
|
+
end
|
|
310
|
+
end
|
|
311
|
+
end
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
module ForestAdminDatasourceIntercom
|
|
2
|
+
module Collections
|
|
3
|
+
# `id equals X` / `id in [...]` answered by the record endpoint, plus exact counting -- shared
|
|
4
|
+
# by the two paginated tiers, where a count over collected pages would answer a fraction.
|
|
5
|
+
module RecordsById
|
|
6
|
+
Operators = ForestAdminDatasourceToolkit::Components::Query::ConditionTree::Operators
|
|
7
|
+
Leaf = ForestAdminDatasourceToolkit::Components::Query::ConditionTree::Nodes::ConditionTreeLeaf
|
|
8
|
+
Aggregation = ForestAdminDatasourceToolkit::Components::Query::Aggregation
|
|
9
|
+
|
|
10
|
+
# How many records a bulk read by id may fetch. Intercom exposes no "read
|
|
11
|
+
# these records" endpoint, so a set of ids is one request each -- the
|
|
12
|
+
# fan-out is bounded rather than turned into a rate limit halfway through
|
|
13
|
+
# a page. The collection that reads its ids in bulk raises the figure.
|
|
14
|
+
MAX_ID_READS = 25
|
|
15
|
+
|
|
16
|
+
protected
|
|
17
|
+
|
|
18
|
+
# The endpoint one record is read from.
|
|
19
|
+
def record_endpoint = raise(NotImplementedError, "#{self.class} did not implement record_endpoint")
|
|
20
|
+
|
|
21
|
+
# What rides along on that read. `display_as=plaintext` for the tiers
|
|
22
|
+
# whose records carry bodies written by end customers; nothing elsewhere.
|
|
23
|
+
def record_read_params = {}
|
|
24
|
+
|
|
25
|
+
def max_id_reads = MAX_ID_READS
|
|
26
|
+
|
|
27
|
+
# A bare primary-key leaf only: answering an `and` carrying a scope, or a search, with the
|
|
28
|
+
# ids alone would serve records it excludes. Deduplicated so an id is not read twice.
|
|
29
|
+
def id_lookup(filter)
|
|
30
|
+
tree = filter&.condition_tree
|
|
31
|
+
return nil unless tree.is_a?(Leaf) && tree.field.to_s == primary_key
|
|
32
|
+
return nil unless blank_search?(filter)
|
|
33
|
+
|
|
34
|
+
case tree.operator
|
|
35
|
+
when Operators::EQUAL then [tree.value].compact.map(&:to_s).uniq
|
|
36
|
+
when Operators::IN then Array(tree.value).compact.map(&:to_s).uniq
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# A record the operator can no longer reach -- deleted, or outside the
|
|
41
|
+
# token's scope -- reads as "no record" rather than as a failed page.
|
|
42
|
+
def records_by_ids(ids)
|
|
43
|
+
refuse_id_reads!(ids.size, 'list') if ids.size > max_id_reads
|
|
44
|
+
|
|
45
|
+
client.fetch_records(record_endpoint, ids, params: record_read_params).compact
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# How many of a set of ids name a record, which means reading them:
|
|
49
|
+
# Intercom has no "how many of these exist" endpoint. Past what a bulk
|
|
50
|
+
# read may fetch the count is refused rather than answered with the number
|
|
51
|
+
# the truncation left -- both paginated tiers advertise an exact count,
|
|
52
|
+
# and "25" where the question named forty records is not one.
|
|
53
|
+
def count_by_ids(ids)
|
|
54
|
+
refuse_id_reads!(ids.size, 'count') if ids.size > max_id_reads
|
|
55
|
+
|
|
56
|
+
records_by_ids(ids).size
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# The count Intercom answered, or nothing at all. Counting the pages a
|
|
60
|
+
# read collected would answer a fraction of the collection as if it were
|
|
61
|
+
# the whole of it, which is the one thing the paginated tiers do not do.
|
|
62
|
+
def exact_count(page)
|
|
63
|
+
return page.total_count if page.total_count
|
|
64
|
+
|
|
65
|
+
raise UnsupportedOperatorError,
|
|
66
|
+
"#{name} cannot be counted: Intercom answered this listing without a total_count, and counting the " \
|
|
67
|
+
'pages the agent read would answer a fraction of the collection as if it were the whole of it.'
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def refuse_unsupported_aggregation!(aggregation)
|
|
71
|
+
return if aggregation.is_a?(Aggregation) && aggregation.operation.to_s.casecmp('count').zero? &&
|
|
72
|
+
Array(aggregation.groups).empty? && aggregation.field.nil?
|
|
73
|
+
|
|
74
|
+
raise UnsupportedOperatorError,
|
|
75
|
+
"#{name} can only be counted: Intercom exposes no aggregate endpoint, and grouping or summing the " \
|
|
76
|
+
'pages the agent read would answer a fraction of the collection as if it were the whole of it. ' \
|
|
77
|
+
'Chart it on a collection read whole, or wait for the bounded group-by of the reporting lot.'
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# The same refusal for a list as for a count: the first `max_id_reads`
|
|
81
|
+
# rows of a longer set of ids look like the whole of it.
|
|
82
|
+
def refuse_id_reads!(asked, verb)
|
|
83
|
+
raise UnsupportedOperatorError,
|
|
84
|
+
"#{name} cannot #{verb} #{asked} records by id: a set of ids is read from Intercom record by " \
|
|
85
|
+
"record or in small batches, and this reads #{max_id_reads} at most. Answering with the ones it read " \
|
|
86
|
+
'would leave the rest out of an answer that looks whole. Ask for fewer records at a time, or use a ' \
|
|
87
|
+
'filter this collection answers instead of a list of ids.'
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def blank_search?(filter)
|
|
91
|
+
search = filter.respond_to?(:search) ? filter.search : nil
|
|
92
|
+
|
|
93
|
+
search.nil? || search.to_s.strip.empty?
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|