forest_admin_datasource_pylon 1.41.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/README.md +179 -0
- data/Rakefile +6 -0
- data/forest_admin_datasource_pylon.gemspec +36 -0
- data/lib/forest_admin_datasource_pylon/client/writes.rb +88 -0
- data/lib/forest_admin_datasource_pylon/client.rb +436 -0
- data/lib/forest_admin_datasource_pylon/collections/account/api_filters.rb +43 -0
- data/lib/forest_admin_datasource_pylon/collections/account/schema_definition.rb +91 -0
- data/lib/forest_admin_datasource_pylon/collections/account/serializer.rb +21 -0
- data/lib/forest_admin_datasource_pylon/collections/account.rb +47 -0
- data/lib/forest_admin_datasource_pylon/collections/base_collection.rb +563 -0
- data/lib/forest_admin_datasource_pylon/collections/contact/api_filters.rb +38 -0
- data/lib/forest_admin_datasource_pylon/collections/contact/schema_definition.rb +93 -0
- data/lib/forest_admin_datasource_pylon/collections/contact/serializer.rb +20 -0
- data/lib/forest_admin_datasource_pylon/collections/contact.rb +45 -0
- data/lib/forest_admin_datasource_pylon/collections/cursor_collection.rb +131 -0
- data/lib/forest_admin_datasource_pylon/collections/fetch_all_collection.rb +192 -0
- data/lib/forest_admin_datasource_pylon/collections/issue/api_filters.rb +41 -0
- data/lib/forest_admin_datasource_pylon/collections/issue/id_lookup_reader.rb +88 -0
- data/lib/forest_admin_datasource_pylon/collections/issue/messages_embedder.rb +89 -0
- data/lib/forest_admin_datasource_pylon/collections/issue/schema_definition.rb +122 -0
- data/lib/forest_admin_datasource_pylon/collections/issue/serializer.rb +26 -0
- data/lib/forest_admin_datasource_pylon/collections/issue.rb +128 -0
- data/lib/forest_admin_datasource_pylon/collections/record_serialization.rb +84 -0
- data/lib/forest_admin_datasource_pylon/collections/relation_embedder.rb +101 -0
- data/lib/forest_admin_datasource_pylon/collections/team.rb +49 -0
- data/lib/forest_admin_datasource_pylon/collections/user.rb +69 -0
- data/lib/forest_admin_datasource_pylon/collections/writes.rb +417 -0
- data/lib/forest_admin_datasource_pylon/configuration.rb +84 -0
- data/lib/forest_admin_datasource_pylon/datasource.rb +53 -0
- data/lib/forest_admin_datasource_pylon/issue_enums.rb +20 -0
- data/lib/forest_admin_datasource_pylon/pagination/cursor_walker.rb +103 -0
- data/lib/forest_admin_datasource_pylon/plugins/close_issue/messages.rb +62 -0
- data/lib/forest_admin_datasource_pylon/plugins/close_issue.rb +141 -0
- data/lib/forest_admin_datasource_pylon/plugins/create_issue_with_notification/form_builder.rb +173 -0
- data/lib/forest_admin_datasource_pylon/plugins/create_issue_with_notification/payload.rb +72 -0
- data/lib/forest_admin_datasource_pylon/plugins/create_issue_with_notification.rb +175 -0
- data/lib/forest_admin_datasource_pylon/plugins/issue_targets.rb +51 -0
- data/lib/forest_admin_datasource_pylon/query/condition_tree_translator.rb +151 -0
- data/lib/forest_admin_datasource_pylon/query/filter_value.rb +135 -0
- data/lib/forest_admin_datasource_pylon/query/operator_maps.rb +108 -0
- data/lib/forest_admin_datasource_pylon/rate_limiter.rb +139 -0
- data/lib/forest_admin_datasource_pylon/rate_limits.rb +96 -0
- data/lib/forest_admin_datasource_pylon/retry_policy.rb +86 -0
- data/lib/forest_admin_datasource_pylon/schema/custom_fields_introspector.rb +203 -0
- data/lib/forest_admin_datasource_pylon/throttle.rb +21 -0
- data/lib/forest_admin_datasource_pylon/version.rb +3 -0
- data/lib/forest_admin_datasource_pylon.rb +70 -0
- metadata +152 -0
|
@@ -0,0 +1,563 @@
|
|
|
1
|
+
module ForestAdminDatasourcePylon
|
|
2
|
+
module Collections
|
|
3
|
+
class BaseCollection < ForestAdminDatasourceToolkit::Collection
|
|
4
|
+
include Writes
|
|
5
|
+
|
|
6
|
+
ColumnSchema = ForestAdminDatasourceToolkit::Schema::ColumnSchema
|
|
7
|
+
ManyToOneSchema = ForestAdminDatasourceToolkit::Schema::Relations::ManyToOneSchema
|
|
8
|
+
OneToManySchema = ForestAdminDatasourceToolkit::Schema::Relations::OneToManySchema
|
|
9
|
+
Operators = ForestAdminDatasourceToolkit::Components::Query::ConditionTree::Operators
|
|
10
|
+
Branch = ForestAdminDatasourceToolkit::Components::Query::ConditionTree::Nodes::ConditionTreeBranch
|
|
11
|
+
Leaf = ForestAdminDatasourceToolkit::Components::Query::ConditionTree::Nodes::ConditionTreeLeaf
|
|
12
|
+
ConditionTreeFactory = ForestAdminDatasourceToolkit::Components::Query::ConditionTree::ConditionTreeFactory
|
|
13
|
+
Equivalent = ForestAdminDatasourceToolkit::Components::Query::ConditionTree::ConditionTreeEquivalent
|
|
14
|
+
SortFactory = ForestAdminDatasourceToolkit::Components::Query::SortUtils::SortFactory
|
|
15
|
+
Filter = ForestAdminDatasourceToolkit::Components::Query::Filter
|
|
16
|
+
Page = ForestAdminDatasourceToolkit::Components::Query::Page
|
|
17
|
+
Projection = ForestAdminDatasourceToolkit::Components::Query::Projection
|
|
18
|
+
|
|
19
|
+
# `residual` holds the conditions left over once the primary-key leaf has
|
|
20
|
+
# been taken out of the tree, for the caller to apply in memory.
|
|
21
|
+
IdLookup = Struct.new(:ids, :residual, keyword_init: true)
|
|
22
|
+
|
|
23
|
+
# Mirrors the operators `ConditionTreeLeaf#match` evaluates natively; any
|
|
24
|
+
# other operator needs an equivalence for the column's type to be
|
|
25
|
+
# evaluable in memory.
|
|
26
|
+
IN_MEMORY_OPERATORS = [Operators::IN, Operators::EQUAL, Operators::LESS_THAN, Operators::GREATER_THAN,
|
|
27
|
+
Operators::MATCH, Operators::STARTS_WITH, Operators::ENDS_WITH,
|
|
28
|
+
Operators::LONGER_THAN, Operators::SHORTER_THAN, Operators::INCLUDES_ALL,
|
|
29
|
+
Operators::NOT_IN, Operators::NOT_EQUAL, Operators::NOT_CONTAINS].freeze
|
|
30
|
+
|
|
31
|
+
# The operators `ConditionTreeLeaf#match` evaluates by dereferencing the
|
|
32
|
+
# column value without a nil guard, unlike the string operators which all
|
|
33
|
+
# test `is_a?(String)` first. They need the guard added here.
|
|
34
|
+
NIL_UNSAFE_OPERATORS = [Operators::LESS_THAN, Operators::GREATER_THAN, Operators::INCLUDES_ALL].freeze
|
|
35
|
+
|
|
36
|
+
# How many foreign keys a resolved relation condition may carry. Past this
|
|
37
|
+
# the condition is refused rather than truncated: keeping the first keys
|
|
38
|
+
# would answer a narrower question than the one asked, which is the very
|
|
39
|
+
# thing this datasource refuses — a result that looks filtered and is not.
|
|
40
|
+
MAX_RELATION_KEYS = 500
|
|
41
|
+
|
|
42
|
+
# What a resolved relation condition leaves behind when no foreign record
|
|
43
|
+
# matched it. It is not expressible as a filter — `FilterValue` refuses an
|
|
44
|
+
# empty `in`, whose Pylon meaning is undocumented and would read as "match
|
|
45
|
+
# everything" — so it travels as a marker the read answers with no record.
|
|
46
|
+
MATCHES_NOTHING = :pylon_matches_nothing
|
|
47
|
+
|
|
48
|
+
attr_reader :custom_fields
|
|
49
|
+
|
|
50
|
+
# Template method: subclasses implement `define_schema` and
|
|
51
|
+
# `define_relations` as hooks; ordering between them, custom-field
|
|
52
|
+
# registration, and the search flag is owned here so collisions are always
|
|
53
|
+
# evaluated against the final native schema.
|
|
54
|
+
#
|
|
55
|
+
# No `countable` counterpart: Pylon exposes no count endpoint and no total,
|
|
56
|
+
# and counting the pages a cursor walk collected would answer a fraction of
|
|
57
|
+
# a collection as if it were the whole of it — which is why `aggregate`
|
|
58
|
+
# refuses rather than approximates. See EXT-7.
|
|
59
|
+
def initialize(datasource, name, custom_fields: [], searchable: false, native_driver: nil)
|
|
60
|
+
super(datasource, name, native_driver)
|
|
61
|
+
define_schema
|
|
62
|
+
define_relations
|
|
63
|
+
@custom_fields = add_custom_fields(custom_fields)
|
|
64
|
+
enable_search if searchable
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# How a collection another one points at with a ManyToOne is read in bulk:
|
|
68
|
+
# the serialized records of `ids`, indexed by id, missing ids left out.
|
|
69
|
+
#
|
|
70
|
+
# Public because the caller is the pointing collection, a different object:
|
|
71
|
+
# going through the collection rather than through the client is what keeps
|
|
72
|
+
# a related record serialized by the collection owning its shape, instead
|
|
73
|
+
# of by a second field list kept in the embedder.
|
|
74
|
+
def records_indexed_by_id(_ids)
|
|
75
|
+
raise NotImplementedError, "#{self.class} did not implement records_indexed_by_id"
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Pylon exposes no aggregate endpoint, and the pages of a cursor walk are
|
|
79
|
+
# not the dataset: a count or a group computed over them would look exact
|
|
80
|
+
# while answering a fraction. Every column is registered with
|
|
81
|
+
# `is_groupable: false` so the UI never offers one, and a chart built
|
|
82
|
+
# through the API anyway is refused here rather than through the
|
|
83
|
+
# contract's NotImplementedError, which reads as an oversight.
|
|
84
|
+
#
|
|
85
|
+
# FetchAllCollection, which does hold every record Pylon has, overrides
|
|
86
|
+
# this and answers exactly.
|
|
87
|
+
def aggregate(_caller, _filter, _aggregation, _limit = nil)
|
|
88
|
+
raise UnsupportedOperatorError,
|
|
89
|
+
"#{name} cannot be aggregated: Pylon exposes no aggregate endpoint, and counting or grouping the " \
|
|
90
|
+
'pages the agent walked would answer a fraction of the collection as if it were the whole of it.'
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
protected
|
|
94
|
+
|
|
95
|
+
# Pylon has no `id` filter operator on /issues/search, so collections
|
|
96
|
+
# short-circuit primary-key lookups to /resource/{id}. Ids are UUID
|
|
97
|
+
# strings — unlike Zendesk, nothing has to be coerced to an integer.
|
|
98
|
+
#
|
|
99
|
+
# The leaf is also pulled out of a top-level AND, because Forest sends
|
|
100
|
+
# `AND(id equal X, <scope>)` on a record detail as soon as a scope or a
|
|
101
|
+
# segment is set, and `id` is not a field Pylon can filter on.
|
|
102
|
+
#
|
|
103
|
+
# Every `id` leaf of that AND is taken, not the first: an `and` names the
|
|
104
|
+
# records all of its conditions name, so two of them intersect. Left as a
|
|
105
|
+
# residual, the second would have the cap applied around the wider set and
|
|
106
|
+
# refuse a selection narrower than it — and cost a request per id the
|
|
107
|
+
# intersection drops.
|
|
108
|
+
def extract_id_lookup(node)
|
|
109
|
+
ids = id_values(node)
|
|
110
|
+
return IdLookup.new(ids: ids, residual: nil) if ids
|
|
111
|
+
return nil unless and_branch?(node)
|
|
112
|
+
|
|
113
|
+
named, rest = Array(node.conditions).partition { |condition| id_values(condition) }
|
|
114
|
+
return nil if named.empty?
|
|
115
|
+
|
|
116
|
+
residual = ConditionTreeFactory.intersect(rest)
|
|
117
|
+
ensure_residual_appliable!(residual)
|
|
118
|
+
IdLookup.new(ids: intersect_ids(named), residual: guard_nil_comparisons(residual))
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Disjoint sets name no record, which is the empty lookup — no request —
|
|
122
|
+
# rather than a request per id of the first set for a page that is empty
|
|
123
|
+
# whatever they answer.
|
|
124
|
+
def intersect_ids(conditions)
|
|
125
|
+
conditions.map { |condition| id_values(condition) }.reduce(:&)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Pylon runs the free-text search inside its search endpoint, which the
|
|
129
|
+
# primary-key short-circuit does not go through, and neither the fields it
|
|
130
|
+
# covers nor its fuzziness can be reproduced in memory. Answering with the
|
|
131
|
+
# unsearched record would be the very thing this datasource refuses: a
|
|
132
|
+
# result that looks filtered and is not.
|
|
133
|
+
def ensure_searchless_lookup!(filter)
|
|
134
|
+
search = filter&.search
|
|
135
|
+
return if search.nil? || search.to_s.strip.empty?
|
|
136
|
+
|
|
137
|
+
raise UnsupportedOperatorError,
|
|
138
|
+
"A search cannot be combined with a filter on 'id': Pylon searches through its search endpoint, " \
|
|
139
|
+
'which cannot filter on id, while an id is read through its own endpoint, which cannot search. ' \
|
|
140
|
+
'Clear the search or drop the id condition.'
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Yields the filter with every relation condition resolved into a condition
|
|
144
|
+
# on this collection's own columns, so the routes below — the search and
|
|
145
|
+
# the primary-key lookup, which applies its leftovers in memory — never see
|
|
146
|
+
# a `relation:field` leaf.
|
|
147
|
+
#
|
|
148
|
+
# Answers with no record at all, and no request, when the resolution found
|
|
149
|
+
# nothing to match: see `MATCHES_NOTHING`.
|
|
150
|
+
def with_resolved_relations(caller, filter)
|
|
151
|
+
tree = filter&.condition_tree
|
|
152
|
+
return yield(filter) unless tree&.some_leaf { |leaf| leaf.field.to_s.include?(':') }
|
|
153
|
+
|
|
154
|
+
resolved = resolve_relation_conditions(caller, tree)
|
|
155
|
+
return [] if resolved == MATCHES_NOTHING
|
|
156
|
+
|
|
157
|
+
yield(filter.override(condition_tree: resolved))
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# Forest asks for an offset/limit window, Pylon hands out cursor pages: the
|
|
161
|
+
# walker bridges the two, `search_page` performs one call, and the records
|
|
162
|
+
# it collected are serialized by the collection.
|
|
163
|
+
def search_records(caller, filter)
|
|
164
|
+
pylon_filter = build_pylon_filter(caller, filter)
|
|
165
|
+
search_text = filter&.search
|
|
166
|
+
offset, limit = translate_page(filter&.page)
|
|
167
|
+
|
|
168
|
+
records = walker.walk(offset: offset, limit: limit) do |batch, cursor|
|
|
169
|
+
search_page(limit: batch, cursor: cursor, filter: pylon_filter, search_text: search_text)
|
|
170
|
+
end
|
|
171
|
+
records.map { |record| serialize(record) }
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# One page of the walk, as a Client::SearchPage: the endpoint and its
|
|
175
|
+
# parameter names belong to the collection, the walk does not.
|
|
176
|
+
def search_page(limit:, cursor:, filter:, search_text:)
|
|
177
|
+
raise NotImplementedError, "#{self.class} did not implement search_page"
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# Sliced after the lookup, not before, so ids that resolved to nothing
|
|
181
|
+
# (404) do not eat into the requested window.
|
|
182
|
+
#
|
|
183
|
+
# A filter carrying no page — or a page naming no limit — asks for every
|
|
184
|
+
# record it matched, and the records are already in hand: there is no
|
|
185
|
+
# window to cut. How far the walk that collected them went is a different
|
|
186
|
+
# question, answered by `translate_page` and the caps of the walker.
|
|
187
|
+
def page_window(records, filter)
|
|
188
|
+
page = filter&.page
|
|
189
|
+
return records if page.nil?
|
|
190
|
+
|
|
191
|
+
offset = page.offset.to_i.clamp(0, nil)
|
|
192
|
+
limit = page.limit.to_i
|
|
193
|
+
return records.drop(offset) unless limit.positive?
|
|
194
|
+
|
|
195
|
+
records[offset, limit] || []
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def build_pylon_filter(caller, filter)
|
|
199
|
+
tree = filter&.condition_tree
|
|
200
|
+
ensure_no_stray_id!(tree)
|
|
201
|
+
Query::ConditionTreeTranslator.call(tree, api_filters: api_filters, timezone: timezone_for(caller))
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# The `ApiFilters` module of the collection, whose table is the single
|
|
205
|
+
# source of truth for what its endpoint filters. The empty table is the
|
|
206
|
+
# default: a collection read whole and filtered in memory filters nothing
|
|
207
|
+
# server-side.
|
|
208
|
+
def filter_table = Query::OperatorMaps::EmptyTable
|
|
209
|
+
|
|
210
|
+
# What the endpoint filters server-side: the table of the collection, plus
|
|
211
|
+
# one entry per custom field — filtered through the very Pylon slug it is
|
|
212
|
+
# read by, with the operators the integrator declared on the column.
|
|
213
|
+
def api_filters
|
|
214
|
+
@api_filters ||= custom_fields.each_with_object(filter_table::API_FILTERS.dup) do |cf, filters|
|
|
215
|
+
filters[cf[:column_name]] = filter_table.for_custom_field(cf[:schema])
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
# A native column: read-only unless the collection declares it `writable`,
|
|
220
|
+
# and never groupable, as no Pylon endpoint aggregates. It is not sortable
|
|
221
|
+
# either, the ColumnSchema default, because no search endpoint takes a sort
|
|
222
|
+
# parameter. Filter operators are not chosen here: they come from
|
|
223
|
+
# `filter_table`, which mirrors the allow-list of the API, so a column
|
|
224
|
+
# missing from it gets none and the UI offers no filter Pylon would refuse.
|
|
225
|
+
def add_column(name, type, is_primary_key: false, writable: false)
|
|
226
|
+
add_field(name, ColumnSchema.new(column_type: type,
|
|
227
|
+
filter_operators: filter_table.forest_operators(name),
|
|
228
|
+
is_primary_key: is_primary_key,
|
|
229
|
+
is_groupable: false,
|
|
230
|
+
is_read_only: !writable))
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
# A record read through the endpoint of an id that is not the primary key
|
|
234
|
+
# it answered with. `GET /accounts/{id}` takes an external id and
|
|
235
|
+
# `GET /issues/{id}` an issue number, so the record a lookup hands back
|
|
236
|
+
# can carry an `id` other than the one the filter asked for: keeping it
|
|
237
|
+
# would answer `id equals <alias>` with a row that does not match, where
|
|
238
|
+
# the same filter combined with a scope — which goes through the search
|
|
239
|
+
# endpoint instead — answers nothing at all.
|
|
240
|
+
def matches_id?(record, id)
|
|
241
|
+
record['id'].to_s == id.to_s
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
# An order no endpoint honours is reported rather than silently swallowed:
|
|
245
|
+
# the rows come back in whatever order the API imposes.
|
|
246
|
+
def warn_unsortable(sort)
|
|
247
|
+
return if sort.nil? || sort.empty? || default_pk_sort?(sort)
|
|
248
|
+
return if translate_sort(sort, sortable_fields).first
|
|
249
|
+
|
|
250
|
+
ForestAdminDatasourcePylon.logger.warn(unsortable_warning)
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
# Overridden by collections whose endpoint can sort server-side.
|
|
254
|
+
def sortable_fields
|
|
255
|
+
{}
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
# Overridden to name the order the endpoint imposes instead, which is what
|
|
259
|
+
# tells the operator what they got in place of the order they asked for.
|
|
260
|
+
def unsortable_warning
|
|
261
|
+
"[forest_admin_datasource_pylon] #{name} cannot honour the requested order."
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
# An unknown field silently disables sorting: a Pylon endpoint only honours
|
|
265
|
+
# the fixed allow-list its collection declares.
|
|
266
|
+
def translate_sort(sort, allow_list)
|
|
267
|
+
return [nil, nil] if sort.nil? || sort.empty?
|
|
268
|
+
|
|
269
|
+
field, ascending = sort_field_and_direction(sort.first)
|
|
270
|
+
pylon_field = allow_list[field.to_s]
|
|
271
|
+
return [nil, nil] unless pylon_field
|
|
272
|
+
|
|
273
|
+
[pylon_field, ascending ? 'asc' : 'desc']
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
# The agent injects an ascending primary-key sort whenever the request
|
|
277
|
+
# asks for no order, so the default cannot be told apart from a chosen
|
|
278
|
+
# order by presence alone.
|
|
279
|
+
def default_pk_sort?(sort)
|
|
280
|
+
normalized_sort_clauses(sort) == normalized_sort_clauses(SortFactory.by_primary_keys(self))
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
# The search box sends an empty string once the operator clears it.
|
|
284
|
+
def no_search?(filter)
|
|
285
|
+
filter&.search.to_s.strip.empty?
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
def timezone_for(caller)
|
|
289
|
+
return 'UTC' unless caller.respond_to?(:timezone)
|
|
290
|
+
|
|
291
|
+
timezone = caller.timezone
|
|
292
|
+
timezone.nil? || timezone.to_s.empty? ? 'UTC' : timezone
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
# A projection naming only relations names no column of this collection,
|
|
296
|
+
# and the row it asks for carries none: returning the whole record there
|
|
297
|
+
# would serve every native column under a projection that excluded them,
|
|
298
|
+
# which is what `Projection#re_project` does not do.
|
|
299
|
+
#
|
|
300
|
+
# An empty projection is the other case and not that one — it asks for
|
|
301
|
+
# nothing rather than for relations alone, and means the record as it is,
|
|
302
|
+
# exactly like no projection at all. `ActionContext#get_records` sends one
|
|
303
|
+
# on every action whatever fields it was handed, so a row emptied here
|
|
304
|
+
# costs the plugins the record they act on: `primary_keys` raises on it,
|
|
305
|
+
# and `IssueTargets` reads the raise as "no issue selected".
|
|
306
|
+
def project(record, projection)
|
|
307
|
+
return record if projection.nil? || Array(projection).empty?
|
|
308
|
+
|
|
309
|
+
wanted = Array(projection).map(&:to_s).reject { |p| p.include?(':') }
|
|
310
|
+
wanted.to_h { |k| [k, record[k]] }
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
# A filter carrying no page — or a page naming no limit — asks for every
|
|
314
|
+
# record it matched, and travels to the walk as no limit at all rather than
|
|
315
|
+
# as `MAX_SEARCH_LIMIT`: a limit standing in for "everything" is one the
|
|
316
|
+
# walk cannot tell from a window the caller asked for, so it would stop at
|
|
317
|
+
# a thousand records having answered a larger set, and stop silently — the
|
|
318
|
+
# truncation warning only fires on a walk that knows it was cut short.
|
|
319
|
+
def translate_page(page)
|
|
320
|
+
return [0, nil] if page.nil?
|
|
321
|
+
|
|
322
|
+
limit = page.limit.to_i
|
|
323
|
+
[page.offset.to_i.clamp(0, nil), limit.positive? ? limit : nil]
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
# Adds custom fields, skipping any whose column name collides with a
|
|
327
|
+
# field already declared on the collection, and clamping the declared
|
|
328
|
+
# operators to those the API accepts on a custom field — so the schema
|
|
329
|
+
# never advertises a filter the translator would then refuse. Returns
|
|
330
|
+
# the subset actually added, carrying the clamped schemas, so callers
|
|
331
|
+
# can keep their serializer and api_filters in sync with the schema.
|
|
332
|
+
def add_custom_fields(custom_fields)
|
|
333
|
+
custom_fields.filter_map do |cf|
|
|
334
|
+
column_name = cf[:column_name]
|
|
335
|
+
if schema[:fields].key?(column_name)
|
|
336
|
+
ForestAdminDatasourcePylon.logger.warn(
|
|
337
|
+
"[forest_admin_datasource_pylon] Custom field '#{column_name}' on collection " \
|
|
338
|
+
"'#{name}' conflicts with an existing field; skipping."
|
|
339
|
+
)
|
|
340
|
+
nil
|
|
341
|
+
else
|
|
342
|
+
clamped = clamp_custom_field_operators(column_name, cf[:schema])
|
|
343
|
+
add_field(column_name, clamped)
|
|
344
|
+
cf.merge(schema: clamped)
|
|
345
|
+
end
|
|
346
|
+
end
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
# Operators a custom field may advertise: the ones the endpoint accepts on
|
|
350
|
+
# one, read off the same table the native columns come from. Declarations
|
|
351
|
+
# outside this list are dropped at registration, so the schema never
|
|
352
|
+
# advertises an operator the translator would refuse — and the empty table
|
|
353
|
+
# of a collection filtering nothing server-side advertises none.
|
|
354
|
+
def allowed_custom_field_operators
|
|
355
|
+
filter_table::CUSTOM_FIELD_OPS.keys
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
private
|
|
359
|
+
|
|
360
|
+
def define_schema = raise(NotImplementedError, "#{self.class} did not implement define_schema")
|
|
361
|
+
def define_relations = raise(NotImplementedError, "#{self.class} did not implement define_relations")
|
|
362
|
+
|
|
363
|
+
def walker
|
|
364
|
+
@walker ||= Pagination::CursorWalker.new
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
# A set of ids, not a list: the same one named twice is one record, so a
|
|
368
|
+
# lookup spends one request on it and a delete does not answer 404 the
|
|
369
|
+
# second time. The caps count records rather than mentions for the same
|
|
370
|
+
# reason.
|
|
371
|
+
def id_values(node)
|
|
372
|
+
return nil unless node.is_a?(Leaf) && node.field == 'id'
|
|
373
|
+
return nil unless [Operators::EQUAL, Operators::IN].include?(node.operator)
|
|
374
|
+
|
|
375
|
+
Array(node.value).map(&:to_s).reject(&:empty?).uniq
|
|
376
|
+
end
|
|
377
|
+
|
|
378
|
+
def and_branch?(node)
|
|
379
|
+
node.is_a?(Branch) && node.aggregator.to_s.casecmp('and').zero?
|
|
380
|
+
end
|
|
381
|
+
|
|
382
|
+
# An `id` the short-circuit could not take out of the tree has no
|
|
383
|
+
# translation left: the endpoint filters no id server-side, and an id under
|
|
384
|
+
# an OR cannot be narrowed to a lookup because the other side of the union
|
|
385
|
+
# would bring in records the lookup never fetched. Worth an error an
|
|
386
|
+
# operator can act on rather than the translator's "add it to api_filters",
|
|
387
|
+
# because two things they do reach it: the `id equals` filter next to the
|
|
388
|
+
# or/and toggle, and an excluding selection — "every record except these" —
|
|
389
|
+
# which arrives as `id not_in` and is no filter they wrote.
|
|
390
|
+
#
|
|
391
|
+
# A collection whose endpoint does filter id declares it in `api_filters`
|
|
392
|
+
# and never short-circuits, so the translator handles its ids like any
|
|
393
|
+
# other field and there is nothing to refuse.
|
|
394
|
+
def ensure_no_stray_id!(node)
|
|
395
|
+
return if node.nil? || api_filters.key?('id')
|
|
396
|
+
return unless node.some_leaf { |leaf| leaf.field == 'id' }
|
|
397
|
+
|
|
398
|
+
raise UnsupportedOperatorError,
|
|
399
|
+
"#{name} cannot answer this selection: Pylon cannot filter on id, so the agent reads the records " \
|
|
400
|
+
'by id and applies the rest in memory, which only an `and` of `id equals` / `id in` conditions ' \
|
|
401
|
+
'names a set of records to read. An id inside an `or` names none, and neither does an exclusion, ' \
|
|
402
|
+
'which is what selecting every record except a few sends. Select the records to act on rather ' \
|
|
403
|
+
'than the ones to leave out, rewrite the filter with `and`, or filter on another field.'
|
|
404
|
+
end
|
|
405
|
+
|
|
406
|
+
def resolve_relation_conditions(caller, node)
|
|
407
|
+
return resolve_relation_branch(caller, node) if node.is_a?(Branch)
|
|
408
|
+
return node unless node.field.to_s.include?(':')
|
|
409
|
+
|
|
410
|
+
resolve_relation_leaf(caller, node)
|
|
411
|
+
end
|
|
412
|
+
|
|
413
|
+
# An unmatchable condition empties an `and` and drops out of an `or`, which
|
|
414
|
+
# is how it would behave had it been sent as a condition on a column no
|
|
415
|
+
# record answers.
|
|
416
|
+
def resolve_relation_branch(caller, branch)
|
|
417
|
+
resolved = Array(branch.conditions).map { |condition| resolve_relation_conditions(caller, condition) }
|
|
418
|
+
return MATCHES_NOTHING if and_branch?(branch) && resolved.include?(MATCHES_NOTHING)
|
|
419
|
+
return Branch.new(branch.aggregator, resolved) if and_branch?(branch)
|
|
420
|
+
|
|
421
|
+
kept = resolved.reject { |condition| condition == MATCHES_NOTHING }
|
|
422
|
+
kept.empty? ? MATCHES_NOTHING : Branch.new(branch.aggregator, kept)
|
|
423
|
+
end
|
|
424
|
+
|
|
425
|
+
# Pylon has neither a join nor an include parameter, so a `relation:field`
|
|
426
|
+
# leaf — which Forest offers as soon as a ManyToOne is declared, and which
|
|
427
|
+
# the schema therefore advertises as filterable — has no translation as it
|
|
428
|
+
# stands. It is resolved instead, the way `RelationCollectionDecorator`
|
|
429
|
+
# resolves one on a relation the customizer added: the foreign collection
|
|
430
|
+
# is read for the keys of the records matching the condition, and the leaf
|
|
431
|
+
# becomes the `foreign_key in [...]` this collection does filter.
|
|
432
|
+
#
|
|
433
|
+
# The read is the foreign collection's own, so its endpoint, its operators
|
|
434
|
+
# and its refusals apply — and a relation no condition names costs nothing,
|
|
435
|
+
# only a filter mentioning it triggers the read.
|
|
436
|
+
def resolve_relation_leaf(caller, leaf)
|
|
437
|
+
relation = schema[:fields][leaf.field.to_s.split(':').first]
|
|
438
|
+
raise_unfilterable_relation(leaf.field) unless resolvable_relation?(relation)
|
|
439
|
+
|
|
440
|
+
keys = foreign_keys_matching(caller, relation, leaf)
|
|
441
|
+
keys.empty? ? MATCHES_NOTHING : Leaf.new(relation.foreign_key, Operators::IN, keys)
|
|
442
|
+
end
|
|
443
|
+
|
|
444
|
+
# A relation is resolvable when its foreign key is a column this collection
|
|
445
|
+
# filters with `in` — server-side through `api_filters` for the collections
|
|
446
|
+
# that search, in memory for the ones read whole. Nothing else is: a
|
|
447
|
+
# OneToMany would have to be matched the other way round, which the schema
|
|
448
|
+
# never advertises as filterable, and a leaf reaching further than one
|
|
449
|
+
# relation is left to the foreign collection, which resolves its own.
|
|
450
|
+
def resolvable_relation?(relation)
|
|
451
|
+
return false unless relation.is_a?(ManyToOneSchema)
|
|
452
|
+
|
|
453
|
+
column = schema[:fields][relation.foreign_key]
|
|
454
|
+
column.is_a?(ColumnSchema) && column.filter_operators.include?(Operators::IN)
|
|
455
|
+
end
|
|
456
|
+
|
|
457
|
+
# One record past the cap is asked for, so an overflow is seen rather than
|
|
458
|
+
# guessed from a full page.
|
|
459
|
+
def foreign_keys_matching(caller, relation, leaf)
|
|
460
|
+
foreign = datasource.get_collection(relation.foreign_collection)
|
|
461
|
+
target = relation.foreign_key_target
|
|
462
|
+
query = Filter.new(condition_tree: leaf.unnest,
|
|
463
|
+
page: Page.new(offset: 0, limit: MAX_RELATION_KEYS + 1))
|
|
464
|
+
|
|
465
|
+
records = foreign.list(caller, query, Projection.new([target]))
|
|
466
|
+
raise_too_many_relation_keys(leaf.field, relation) if records.size > MAX_RELATION_KEYS
|
|
467
|
+
|
|
468
|
+
records.filter_map { |record| record[target] }.uniq
|
|
469
|
+
end
|
|
470
|
+
|
|
471
|
+
def raise_unfilterable_relation(field)
|
|
472
|
+
relation = schema[:fields][field.to_s.split(':').first]
|
|
473
|
+
instead = if relation.respond_to?(:foreign_key)
|
|
474
|
+
"Filter on '#{relation.foreign_key}' instead, or set the filter from the " \
|
|
475
|
+
"#{relation.foreign_collection} list."
|
|
476
|
+
else
|
|
477
|
+
'Filter on a column of this collection instead.'
|
|
478
|
+
end
|
|
479
|
+
|
|
480
|
+
raise UnsupportedOperatorError,
|
|
481
|
+
"Pylon cannot filter on the related field '#{field}': it has no join, so a condition on a " \
|
|
482
|
+
'relation is answered by reading the foreign collection for its keys, which this relation ' \
|
|
483
|
+
"does not allow. #{instead}"
|
|
484
|
+
end
|
|
485
|
+
|
|
486
|
+
def raise_too_many_relation_keys(field, relation)
|
|
487
|
+
raise UnsupportedOperatorError,
|
|
488
|
+
"The filter on '#{field}' matches more than #{MAX_RELATION_KEYS} #{relation.foreign_collection} " \
|
|
489
|
+
'records: Pylon has no join, so the condition travels as the list of their keys, and a list this ' \
|
|
490
|
+
'long is one the endpoint cannot carry. Narrow the condition on the related field, or filter on ' \
|
|
491
|
+
"'#{relation.foreign_key}' directly."
|
|
492
|
+
end
|
|
493
|
+
|
|
494
|
+
def clamp_custom_field_operators(column_name, schema)
|
|
495
|
+
declared = Array(schema.filter_operators)
|
|
496
|
+
dropped = declared - allowed_custom_field_operators
|
|
497
|
+
return schema if dropped.empty?
|
|
498
|
+
|
|
499
|
+
ForestAdminDatasourcePylon.logger.warn(
|
|
500
|
+
"[forest_admin_datasource_pylon] Custom field '#{column_name}' on collection '#{name}' declares " \
|
|
501
|
+
"operators the API cannot honour on a custom field (#{dropped.join(", ")}); they are not advertised."
|
|
502
|
+
)
|
|
503
|
+
schema.dup.tap { |clamped| clamped.filter_operators = declared - dropped }
|
|
504
|
+
end
|
|
505
|
+
|
|
506
|
+
# A residual is evaluated by `ConditionTree#apply`, which compares scalar
|
|
507
|
+
# values: a Json column holds a list whose Pylon membership semantics
|
|
508
|
+
# have no in-memory counterpart, and an operator without an equivalence
|
|
509
|
+
# for the column's type has no in-memory evaluation at all. Both would
|
|
510
|
+
# silently corrupt the lookup's result, so they are refused instead.
|
|
511
|
+
def ensure_residual_appliable!(node)
|
|
512
|
+
case node
|
|
513
|
+
when Branch then node.conditions.each { |condition| ensure_residual_appliable!(condition) }
|
|
514
|
+
when Leaf then raise_unappliable_residual(node) unless residual_leaf_appliable?(node)
|
|
515
|
+
end
|
|
516
|
+
end
|
|
517
|
+
|
|
518
|
+
def residual_leaf_appliable?(leaf)
|
|
519
|
+
column = schema[:fields][leaf.field]
|
|
520
|
+
return false if column.nil? || column.column_type == 'Json'
|
|
521
|
+
|
|
522
|
+
Equivalent.equivalent_tree?(leaf.operator, IN_MEMORY_OPERATORS, column.column_type)
|
|
523
|
+
end
|
|
524
|
+
|
|
525
|
+
# `ConditionTreeLeaf#match` compares with a bare `<` / `>`, which raises a
|
|
526
|
+
# NoMethodError on a column Pylon leaves null -- `resolution_time` on an
|
|
527
|
+
# unresolved issue, for one. Pairing the comparison with a presence check
|
|
528
|
+
# reproduces what a database does with NULL, excluding the record, and
|
|
529
|
+
# rides on the in-memory equivalence PRESENT already has for every type.
|
|
530
|
+
def guard_nil_comparisons(node)
|
|
531
|
+
return nil if node.nil?
|
|
532
|
+
|
|
533
|
+
node.replace_leafs do |leaf|
|
|
534
|
+
next leaf unless NIL_UNSAFE_OPERATORS.include?(leaf.operator)
|
|
535
|
+
|
|
536
|
+
ConditionTreeFactory.intersect([Leaf.new(leaf.field, Operators::PRESENT), leaf])
|
|
537
|
+
end
|
|
538
|
+
end
|
|
539
|
+
|
|
540
|
+
def raise_unappliable_residual(leaf)
|
|
541
|
+
raise UnsupportedOperatorError,
|
|
542
|
+
"Operator '#{leaf.operator}' on field '#{leaf.field}' cannot be combined with a primary-key " \
|
|
543
|
+
'lookup: Pylon cannot filter on id server-side, so the other conditions run in memory, ' \
|
|
544
|
+
'which this one does not support.'
|
|
545
|
+
end
|
|
546
|
+
|
|
547
|
+
def sort_field_and_direction(entry)
|
|
548
|
+
return [entry.field, entry.ascending] if entry.respond_to?(:field)
|
|
549
|
+
|
|
550
|
+
field = entry.key?(:field) ? entry[:field] : entry['field']
|
|
551
|
+
ascending = entry.key?(:ascending) ? entry[:ascending] : entry['ascending']
|
|
552
|
+
[field, ascending]
|
|
553
|
+
end
|
|
554
|
+
|
|
555
|
+
def normalized_sort_clauses(sort)
|
|
556
|
+
Array(sort).map do |entry|
|
|
557
|
+
field, ascending = sort_field_and_direction(entry)
|
|
558
|
+
[field.to_s, ascending]
|
|
559
|
+
end
|
|
560
|
+
end
|
|
561
|
+
end
|
|
562
|
+
end
|
|
563
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
module ForestAdminDatasourcePylon
|
|
2
|
+
module Collections
|
|
3
|
+
class Contact < CursorCollection
|
|
4
|
+
# The allow-list of `POST /contacts/search`, transcribed from the API
|
|
5
|
+
# reference: a field absent from this table cannot be filtered at all, and
|
|
6
|
+
# an operator absent from a field's map is rejected by Pylon.
|
|
7
|
+
#
|
|
8
|
+
# It is the single source of truth for filtering — `define_schema` derives
|
|
9
|
+
# every column's `filter_operators` from it, so this collection declares no
|
|
10
|
+
# filter the translator would then refuse; the absence family the agent
|
|
11
|
+
# derives on top of it is the exception `Query::OperatorMaps::Table`
|
|
12
|
+
# describes.
|
|
13
|
+
module ApiFilters
|
|
14
|
+
Maps = Query::OperatorMaps
|
|
15
|
+
|
|
16
|
+
extend Maps::Table
|
|
17
|
+
|
|
18
|
+
CUSTOM_FIELD_OPS = Maps::CUSTOM_FIELD_OPS
|
|
19
|
+
|
|
20
|
+
# `id` is filtered server-side here, which is what spares this
|
|
21
|
+
# collection the primary-key short-circuit Issue needs.
|
|
22
|
+
#
|
|
23
|
+
# `name` and `email` get SUBSTRING rather than FULL_TEXT: the endpoint
|
|
24
|
+
# accepts `string_contains` but no negation of it. `email` filters the
|
|
25
|
+
# primary address only, not the `emails` list.
|
|
26
|
+
#
|
|
27
|
+
# The contacts search offers nothing else: no presence check, no
|
|
28
|
+
# filter on the phone numbers, the portal role or the external ids.
|
|
29
|
+
API_FILTERS = {
|
|
30
|
+
'id' => { ops: Maps::EQUALITY },
|
|
31
|
+
'name' => { ops: Maps::EQUALITY.merge(Maps::SUBSTRING) },
|
|
32
|
+
'email' => { ops: Maps::EQUALITY.merge(Maps::SUBSTRING) },
|
|
33
|
+
'account_id' => { ops: Maps::EQUALITY }
|
|
34
|
+
}.freeze
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|