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.
Files changed (50) hide show
  1. checksums.yaml +7 -0
  2. data/.rspec +3 -0
  3. data/README.md +179 -0
  4. data/Rakefile +6 -0
  5. data/forest_admin_datasource_pylon.gemspec +36 -0
  6. data/lib/forest_admin_datasource_pylon/client/writes.rb +88 -0
  7. data/lib/forest_admin_datasource_pylon/client.rb +436 -0
  8. data/lib/forest_admin_datasource_pylon/collections/account/api_filters.rb +43 -0
  9. data/lib/forest_admin_datasource_pylon/collections/account/schema_definition.rb +91 -0
  10. data/lib/forest_admin_datasource_pylon/collections/account/serializer.rb +21 -0
  11. data/lib/forest_admin_datasource_pylon/collections/account.rb +47 -0
  12. data/lib/forest_admin_datasource_pylon/collections/base_collection.rb +563 -0
  13. data/lib/forest_admin_datasource_pylon/collections/contact/api_filters.rb +38 -0
  14. data/lib/forest_admin_datasource_pylon/collections/contact/schema_definition.rb +93 -0
  15. data/lib/forest_admin_datasource_pylon/collections/contact/serializer.rb +20 -0
  16. data/lib/forest_admin_datasource_pylon/collections/contact.rb +45 -0
  17. data/lib/forest_admin_datasource_pylon/collections/cursor_collection.rb +131 -0
  18. data/lib/forest_admin_datasource_pylon/collections/fetch_all_collection.rb +192 -0
  19. data/lib/forest_admin_datasource_pylon/collections/issue/api_filters.rb +41 -0
  20. data/lib/forest_admin_datasource_pylon/collections/issue/id_lookup_reader.rb +88 -0
  21. data/lib/forest_admin_datasource_pylon/collections/issue/messages_embedder.rb +89 -0
  22. data/lib/forest_admin_datasource_pylon/collections/issue/schema_definition.rb +122 -0
  23. data/lib/forest_admin_datasource_pylon/collections/issue/serializer.rb +26 -0
  24. data/lib/forest_admin_datasource_pylon/collections/issue.rb +128 -0
  25. data/lib/forest_admin_datasource_pylon/collections/record_serialization.rb +84 -0
  26. data/lib/forest_admin_datasource_pylon/collections/relation_embedder.rb +101 -0
  27. data/lib/forest_admin_datasource_pylon/collections/team.rb +49 -0
  28. data/lib/forest_admin_datasource_pylon/collections/user.rb +69 -0
  29. data/lib/forest_admin_datasource_pylon/collections/writes.rb +417 -0
  30. data/lib/forest_admin_datasource_pylon/configuration.rb +84 -0
  31. data/lib/forest_admin_datasource_pylon/datasource.rb +53 -0
  32. data/lib/forest_admin_datasource_pylon/issue_enums.rb +20 -0
  33. data/lib/forest_admin_datasource_pylon/pagination/cursor_walker.rb +103 -0
  34. data/lib/forest_admin_datasource_pylon/plugins/close_issue/messages.rb +62 -0
  35. data/lib/forest_admin_datasource_pylon/plugins/close_issue.rb +141 -0
  36. data/lib/forest_admin_datasource_pylon/plugins/create_issue_with_notification/form_builder.rb +173 -0
  37. data/lib/forest_admin_datasource_pylon/plugins/create_issue_with_notification/payload.rb +72 -0
  38. data/lib/forest_admin_datasource_pylon/plugins/create_issue_with_notification.rb +175 -0
  39. data/lib/forest_admin_datasource_pylon/plugins/issue_targets.rb +51 -0
  40. data/lib/forest_admin_datasource_pylon/query/condition_tree_translator.rb +151 -0
  41. data/lib/forest_admin_datasource_pylon/query/filter_value.rb +135 -0
  42. data/lib/forest_admin_datasource_pylon/query/operator_maps.rb +108 -0
  43. data/lib/forest_admin_datasource_pylon/rate_limiter.rb +139 -0
  44. data/lib/forest_admin_datasource_pylon/rate_limits.rb +96 -0
  45. data/lib/forest_admin_datasource_pylon/retry_policy.rb +86 -0
  46. data/lib/forest_admin_datasource_pylon/schema/custom_fields_introspector.rb +203 -0
  47. data/lib/forest_admin_datasource_pylon/throttle.rb +21 -0
  48. data/lib/forest_admin_datasource_pylon/version.rb +3 -0
  49. data/lib/forest_admin_datasource_pylon.rb +70 -0
  50. 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