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,93 @@
1
+ module ForestAdminDatasourcePylon
2
+ module Collections
3
+ class Contact < CursorCollection
4
+ # A column is writable when `POST /contacts` or `PATCH /contacts/{id}`
5
+ # accepts it, in the shape it is read under — the Json columns holding
6
+ # objects rather than plain strings are left read-only, see below. No
7
+ # column is sortable — neither `GET /contacts` nor `POST /contacts/search`
8
+ # exposes a sort parameter, so advertising a sortable column would let the
9
+ # UI ask for an order the API cannot honour.
10
+ #
11
+ # Filter operators are not chosen here: they come from
12
+ # `ApiFilters::API_FILTERS`, which mirrors the allow-list of the API. A
13
+ # column missing from that table gets no operator, so the UI offers no
14
+ # filter of this collection's own that Pylon would refuse — the absence
15
+ # family the agent derives above the datasource being the exception
16
+ # `Query::OperatorMaps::Table` describes. A contact carries no timestamp at all —
17
+ # Pylon returns none.
18
+ module SchemaDefinition
19
+ ManyToOneSchema = BaseCollection::ManyToOneSchema
20
+ OneToManySchema = BaseCollection::OneToManySchema
21
+
22
+ private
23
+
24
+ def define_schema
25
+ define_identity_fields
26
+ define_contact_fields
27
+ define_portal_fields
28
+ end
29
+
30
+ # `account_id` is both the key of the relation and a column the contacts
31
+ # search filters, which is what lets the account side list its contacts
32
+ # server-side and the embedder resolve the account of a page of contacts
33
+ # in one request.
34
+ #
35
+ # `requested_issues` rather than `issues`: a contact is the requester of
36
+ # an issue, never its assignee — that side belongs to PylonUser.
37
+ def define_relations
38
+ add_field('account', ManyToOneSchema.new(foreign_collection: 'PylonAccount',
39
+ foreign_key: 'account_id', foreign_key_target: 'id'))
40
+ add_field('requested_issues', OneToManySchema.new(foreign_collection: 'PylonIssue',
41
+ origin_key: 'requester_id', origin_key_target: 'id'))
42
+ end
43
+
44
+ def define_identity_fields
45
+ add_column('id', 'String', is_primary_key: true)
46
+ add_column('name', 'String', writable: true)
47
+ # Flattened from the nested `{ id: ..., external_ids: ... }` object
48
+ # Pylon returns, and kept as a column next to the `account` relation
49
+ # it is the key of: the search endpoint filters it. Writable, which is
50
+ # what opens the relation editor — see the party fields of PylonIssue.
51
+ add_column('account_id', 'String', writable: true)
52
+ # Read-only Json, and deliberately unfilterable although the search
53
+ # endpoint does not offer it either: the API matches bare external-id
54
+ # strings while the column shows `{external_id, label}` objects, so a
55
+ # filter would run on something the operator cannot see.
56
+ add_column('external_ids', 'Json')
57
+ end
58
+
59
+ # `email` and `primary_phone_number` carry the primary value; the lists
60
+ # hold every address and number, and neither list is filterable.
61
+ #
62
+ # `email` is written on a create and `emails` on an update, one direction
63
+ # each: `POST /contacts` takes the primary address alone, and the other
64
+ # ones are set on an existing contact. Two writable projections of the
65
+ # same addresses would otherwise travel in one patch, the list leaving
66
+ # out whatever the primary carries.
67
+ #
68
+ # `phone_numbers` is not writable at all: it holds objects, and the
69
+ # shape the endpoint takes them in is not the one the column shows.
70
+ def define_contact_fields
71
+ add_column('email', 'String', writable: true)
72
+ add_column('emails', 'Json', writable: true)
73
+ add_column('primary_phone_number', 'String', writable: true)
74
+ add_column('phone_numbers', 'Json')
75
+ add_column('avatar_url', 'String', writable: true)
76
+ end
77
+
78
+ def define_portal_fields
79
+ # Left as String rather than Enum: Pylon documents no_access / member
80
+ # / admin, but an organization can define its own portal roles, which
81
+ # is what `portal_role_id` points at. The id is the one written and the
82
+ # name is read-only, like `role_id` and `role_name` on PylonUser:
83
+ # whichever of two projections Pylon ignored would come back stale.
84
+ add_column('portal_role', 'String')
85
+ add_column('portal_role_id', 'String', writable: true)
86
+ # Owned by the integrations the contact was seen through; no endpoint
87
+ # takes it.
88
+ add_column('integration_user_ids', 'Json')
89
+ end
90
+ end
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,20 @@
1
+ module ForestAdminDatasourcePylon
2
+ module Collections
3
+ class Contact < CursorCollection
4
+ module Serializer
5
+ NATIVE_FIELDS = %w[id name email emails primary_phone_number phone_numbers avatar_url
6
+ portal_role portal_role_id external_ids integration_user_ids].freeze
7
+
8
+ private
9
+
10
+ def serialize(contact)
11
+ attrs = contact.is_a?(Hash) ? contact : {}
12
+ record = NATIVE_FIELDS.to_h { |field| [field, attrs[field]] }
13
+ record['account_id'] = nested_id(attrs['account'])
14
+ add_custom_field_values(record, attrs['custom_fields'])
15
+ record
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,45 @@
1
+ module ForestAdminDatasourcePylon
2
+ module Collections
3
+ class Contact < CursorCollection
4
+ include SchemaDefinition
5
+ include Serializer
6
+
7
+ # `POST /contacts` takes the primary address alone; the other ones are set
8
+ # on an existing contact, through the list.
9
+ CREATE_ONLY = %w[email].freeze
10
+ UPDATE_ONLY = %w[emails].freeze
11
+
12
+ def initialize(datasource, custom_fields: [])
13
+ super(datasource, 'PylonContact', custom_fields: custom_fields, searchable: true)
14
+ end
15
+
16
+ protected
17
+
18
+ def filter_table = ApiFilters
19
+
20
+ def create_record(payload) = datasource.client.create_contact(payload)
21
+ def update_record(id, payload) = datasource.client.update_contact(id, payload)
22
+ def delete_record(id) = datasource.client.delete_contact(id)
23
+
24
+ def create_only_fields = CREATE_ONLY
25
+ def update_only_fields = UPDATE_ONLY
26
+
27
+ def unsortable_warning
28
+ '[forest_admin_datasource_pylon] PylonContact cannot honour the requested order; neither GET /contacts ' \
29
+ 'nor POST /contacts/search takes a sort parameter, so contacts come back in the order the API imposes.'
30
+ end
31
+
32
+ def search_page(limit:, cursor:, filter:, search_text:)
33
+ datasource.client.search_contacts(limit: limit, cursor: cursor, filter: filter, search_text: search_text)
34
+ end
35
+
36
+ def list_page(limit:, cursor:)
37
+ datasource.client.list_contacts(limit: limit, cursor: cursor)
38
+ end
39
+
40
+ def fetch_one(id)
41
+ datasource.client.fetch_contact(id)
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,131 @@
1
+ module ForestAdminDatasourcePylon
2
+ module Collections
3
+ # Base for the collections Pylon exposes through three endpoints: a plain
4
+ # cursor-paginated listing (`GET /accounts`, `GET /contacts`: 300 requests
5
+ # per minute), a search over the same pages (`POST /accounts/search`: 120)
6
+ # and a single record (`GET /accounts/{id}`: 300).
7
+ #
8
+ # Their search endpoint filters `id` server-side, so — unlike Issue — they
9
+ # declare it in `api_filters` and never need the primary-key short-circuit:
10
+ # every predicate, `id` included and under an `or` as well, is translated and
11
+ # answered by one search request. The routing below is therefore only about
12
+ # spending the cheapest budget that answers the question exactly, never about
13
+ # what Pylon can express.
14
+ #
15
+ # None of these endpoints takes a sort parameter, so `sortable_fields` stays
16
+ # the empty default of the base and each collection names, through
17
+ # `unsortable_warning`, the order it got instead of the one it asked for.
18
+ class CursorCollection < BaseCollection
19
+ include RecordSerialization
20
+ include RelationEmbedder
21
+
22
+ # Pylon documents no maximum number of values on an `in` filter; the chunk
23
+ # keeps the request body and the page answering it bounded.
24
+ ID_CHUNK_SIZE = 100
25
+
26
+ def list(caller, filter, projection)
27
+ records = fetch_records(caller, filter)
28
+ rows = records.map { |record| project(record, projection) }
29
+ embed_relations(records, rows, projection)
30
+ rows
31
+ end
32
+
33
+ # The search endpoint filters `id` server-side, which is what lets a whole
34
+ # page of foreign keys be read in one request per chunk.
35
+ def records_indexed_by_id(ids)
36
+ ids.each_slice(ID_CHUNK_SIZE).with_object({}) do |chunk, indexed|
37
+ search_by_ids(chunk).each { |record| indexed[record['id']] = record }
38
+ end
39
+ end
40
+
41
+ protected
42
+
43
+ # Every collection read this way has a search endpoint, hence a table of
44
+ # its own: the empty default of the base would silently turn each of its
45
+ # filters into a refusal.
46
+ def filter_table = raise(NotImplementedError, "#{self.class} did not implement filter_table")
47
+
48
+ # One page of the listing endpoint, as a Client::SearchPage.
49
+ def list_page(limit:, cursor:) = raise(NotImplementedError, "#{self.class} did not implement list_page")
50
+
51
+ # One record straight from its own endpoint.
52
+ def fetch_one(id) = raise(NotImplementedError, "#{self.class} did not implement fetch_one")
53
+
54
+ private
55
+
56
+ # The `id` filter goes through the translator rather than being written by
57
+ # hand, so the shape on the wire is the one this collection's `api_filters`
58
+ # produce — one spelling of an id filter, not two to keep in step. The
59
+ # cursor is followed defensively: a chunk is asked for as a single page,
60
+ # and Pylon is free to answer it over several.
61
+ def search_by_ids(ids)
62
+ pylon_filter = Query::ConditionTreeTranslator.call(Leaf.new('id', Operators::IN, ids),
63
+ api_filters: api_filters)
64
+ records = walker.walk(offset: 0, limit: ids.size) do |batch, cursor|
65
+ search_page(limit: batch, cursor: cursor, filter: pylon_filter, search_text: nil)
66
+ end
67
+ records.map { |record| serialize(record) }
68
+ end
69
+
70
+ def fetch_records(caller, filter)
71
+ warn_unsortable(filter&.sort)
72
+
73
+ with_resolved_relations(caller, filter) do |query|
74
+ next listed_records(query) if browsing?(query)
75
+
76
+ id = single_id_lookup(query)
77
+ next page_window(records_by_id(id), query) if id
78
+
79
+ search_records(caller, query)
80
+ end
81
+ end
82
+
83
+ # Nothing to filter and nothing to search: the listing endpoint returns
84
+ # the same records for a budget two and a half times larger than the
85
+ # search one.
86
+ def browsing?(filter)
87
+ return true if filter.nil?
88
+
89
+ filter.condition_tree.nil? && no_search?(filter)
90
+ end
91
+
92
+ # The walk of `search_records`, over the listing endpoint: it hands out
93
+ # cursor pages just the same, it only takes no filter.
94
+ def listed_records(filter)
95
+ offset, limit = translate_page(filter&.page)
96
+
97
+ records = walker.walk(offset: offset, limit: limit) { |batch, cursor| list_page(limit: batch, cursor: cursor) }
98
+ records.map { |record| serialize(record) }
99
+ end
100
+
101
+ # A record detail is `id equals X` alone: reading it through the record
102
+ # endpoint keeps the search budget for the pages that need it.
103
+ #
104
+ # Only a bare leaf takes that path. An `and` also carrying a scope is left
105
+ # to the search endpoint, which filters the id and the rest server-side in
106
+ # one request — where a lookup would have to apply the leftovers in memory,
107
+ # and would refuse the ones it cannot evaluate there.
108
+ def single_id_lookup(filter)
109
+ tree = filter.condition_tree
110
+ return nil unless tree.is_a?(Leaf) && no_search?(filter)
111
+
112
+ ids = extract_id_lookup(tree)&.ids
113
+ ids&.one? ? ids.first : nil
114
+ end
115
+
116
+ # A record the operator can no longer reach — deleted, or outside the
117
+ # token's scope — reads as "no record" rather than as a failed page.
118
+ def records_by_id(id)
119
+ record = fetch_one(id)
120
+ return [] if record.nil?
121
+
122
+ serialized = serialize(record)
123
+ matches_id?(serialized, id) ? [serialized] : []
124
+ rescue APIError => e
125
+ raise unless e.status == 404
126
+
127
+ []
128
+ end
129
+ end
130
+ end
131
+ end
@@ -0,0 +1,192 @@
1
+ module ForestAdminDatasourcePylon
2
+ module Collections
3
+ # Base for the collections whose Pylon endpoint hands back the whole
4
+ # collection of the organization in a single response: `GET /users` and
5
+ # `GET /teams` take no cursor, no filter and no sort at all.
6
+ #
7
+ # Filtering, sorting and paginating that response in memory is exact rather
8
+ # than approximate: the records in hand ARE every record Pylon holds, so a
9
+ # window cut out of them carries the same rows a server-side query would
10
+ # have returned. This is what keeps the in-memory pass out of the trap this
11
+ # datasource refuses elsewhere — a result that looks filtered without being
12
+ # filtered — which only arises when a single page of a larger dataset is all
13
+ # one has. The cost is bandwidth, not correctness.
14
+ #
15
+ # Each `list` re-reads the endpoint, so what the operator sees is what Pylon
16
+ # holds now rather than what it held when the process booted. One request
17
+ # per list against the 300 a minute these endpoints grant, spaced out by
18
+ # `RateLimiter`, is a budget no list view comes near.
19
+ class FetchAllCollection < BaseCollection
20
+ # The filters a column may advertise, per column type. Restricted to the
21
+ # operators `ConditionTreeLeaf#match` evaluates natively or
22
+ # `ConditionTreeEquivalent` rewrites into that native set, because the
23
+ # in-memory pass is the only pass there is here: an operator with no
24
+ # equivalence makes `match` return nil, which `apply` reads as "no match"
25
+ # and would silently empty the page instead of filtering it.
26
+ OPERATOR_CANDIDATES = {
27
+ 'String' => [Operators::EQUAL, Operators::NOT_EQUAL, Operators::IN, Operators::NOT_IN,
28
+ Operators::PRESENT, Operators::BLANK, Operators::CONTAINS, Operators::I_CONTAINS,
29
+ Operators::NOT_CONTAINS, Operators::STARTS_WITH, Operators::ENDS_WITH],
30
+ 'Boolean' => [Operators::EQUAL, Operators::NOT_EQUAL, Operators::IN, Operators::NOT_IN,
31
+ Operators::PRESENT, Operators::BLANK]
32
+ }.freeze
33
+
34
+ # Candidates are re-checked against the toolkit rather than trusted, so an
35
+ # equivalence the toolkit stops providing takes the filter out of the
36
+ # schema instead of turning every page using it into an empty one.
37
+ def self.operators_for(column_type)
38
+ Array(OPERATOR_CANDIDATES[column_type]).select do |operator|
39
+ Equivalent.equivalent_tree?(operator, IN_MEMORY_OPERATORS, column_type)
40
+ end
41
+ end
42
+
43
+ # Countable, alone among the Pylon collections: the count answered here is
44
+ # taken over every record Pylon holds, not over the pages a cursor walk
45
+ # happened to collect, so it is the figure a server-side count would have
46
+ # given rather than a fraction of it presented as the whole -- the very
47
+ # reason `BaseCollection#aggregate` refuses everywhere else. One request
48
+ # per count, against the 300 a minute these endpoints grant.
49
+ def initialize(datasource, name, **options)
50
+ super
51
+ enable_count
52
+ end
53
+
54
+ def list(caller, filter, projection)
55
+ records = sort_in_memory(filtered_records(caller, filter), filter&.sort)
56
+
57
+ page_window(records, filter).map { |record| project(record, projection) }
58
+ end
59
+
60
+ # Exact, like the filter and the sort above it: the records in hand are
61
+ # every record Pylon holds, so a count or a group computed over them is
62
+ # the one a server-side aggregation would have answered — which is why
63
+ # these columns stay groupable where every other Pylon column is not.
64
+ #
65
+ # The rows are keyed with strings because that is how the agent reads
66
+ # them, while `Aggregation#apply` hands them back keyed with symbols.
67
+ def aggregate(caller, filter, aggregation, limit = nil)
68
+ aggregation.apply(filtered_records(caller, filter), timezone_for(caller), limit)
69
+ .map { |row| { 'group' => row[:group], 'value' => row[:value] } }
70
+ end
71
+
72
+ # One request answers any number of ids: the endpoint hands back the
73
+ # complete dataset, so the ids only pick rows out of it. Read again on
74
+ # every pass, like `list` — the freshness this collection trades bandwidth
75
+ # for is not worth losing to a cache of related records.
76
+ #
77
+ # Only the wanted entities are serialized: a page of a pointing collection
78
+ # asks for a handful of ids, against every record the organization has.
79
+ def records_indexed_by_id(ids)
80
+ wanted = Array(ids)
81
+
82
+ fetch_all.each_with_object({}) do |entity, indexed|
83
+ next unless entity.is_a?(Hash) && wanted.include?(entity['id'])
84
+
85
+ indexed[entity['id']] = serialize(entity)
86
+ end
87
+ end
88
+
89
+ protected
90
+
91
+ # A column is read-only unless the collection declares it `writable`.
92
+ # Scalar columns are sortable and groupable because the in-memory sort and
93
+ # aggregation honour anything asked of them; a Json column is none of the
94
+ # three, as it holds a list whose Pylon semantics have no in-memory
95
+ # counterpart — the same reason the primary-key residual guard refuses one.
96
+ def add_column(name, type, is_primary_key: false, writable: false)
97
+ add_field(name, ColumnSchema.new(column_type: type,
98
+ filter_operators: self.class.operators_for(type),
99
+ is_primary_key: is_primary_key,
100
+ is_sortable: type != 'Json',
101
+ is_groupable: type != 'Json',
102
+ is_read_only: !writable))
103
+ end
104
+
105
+ # Pylon defines custom fields on issues, accounts and contacts only, so
106
+ # neither collection read this way has any. Refused rather than ignored:
107
+ # `serialize` has no hook here to read a custom-field value with, and the
108
+ # in-memory pass no table to clamp the declared operators against, so a
109
+ # declaration would register a column reading nil on every row forever.
110
+ def add_custom_fields(custom_fields)
111
+ return [] if custom_fields.empty?
112
+
113
+ raise ConfigurationError,
114
+ "#{name} takes no custom field: Pylon defines them on issues, accounts and contacts only."
115
+ end
116
+
117
+ # The complete collection, straight from its unpaginated endpoint.
118
+ def fetch_all = raise(NotImplementedError, "#{self.class} did not implement fetch_all")
119
+
120
+ # One Pylon entity flattened into a record matching the schema.
121
+ def serialize(_entity) = raise(NotImplementedError, "#{self.class} did not implement serialize")
122
+
123
+ private
124
+
125
+ # The complete dataset, serialized and narrowed to the rows the filter
126
+ # keeps: what `list` pages and what `aggregate` counts are the same rows.
127
+ #
128
+ # Relation conditions are resolved first, like everywhere else: neither
129
+ # collection read this way declares a ManyToOne today, so what this refuses
130
+ # is a condition on the reverse side, which `match` would otherwise read as
131
+ # a missing column and answer by dropping every row.
132
+ def filtered_records(caller, filter)
133
+ with_resolved_relations(caller, filter) do |query|
134
+ filter_in_memory(fetch_all.map { |entity| serialize(entity) }, caller, query)
135
+ end
136
+ end
137
+
138
+ # The tree is applied over the complete dataset, so the rows it keeps are
139
+ # the rows Pylon would have kept. `guard_nil_comparisons` is still worth
140
+ # its cost: nothing in the schema advertises a bare comparison, but a
141
+ # scope, a segment or a customizer can send one, and it would otherwise
142
+ # raise on the nulls Pylon returns for an unset column.
143
+ def filter_in_memory(records, caller, filter)
144
+ tree = guard_nil_comparisons(filter&.condition_tree)
145
+ return records if tree.nil?
146
+
147
+ tree.apply(records, self, timezone_for(caller))
148
+ end
149
+
150
+ # Every requested order is honoured, including the ascending primary-key
151
+ # sort the agent injects when the request asks for none, so there is no
152
+ # unsortable order to report.
153
+ #
154
+ # Neither Ruby's `sort` nor the toolkit's `Sort#apply` can be used as is:
155
+ # `sort` is not stable, and `<=>` answers nil on a null, on two booleans
156
+ # and on mixed types, which leaves the comparator undefined and the order
157
+ # arbitrary. Ties therefore fall back to the position the API returned the
158
+ # record in, and values are compared by `compare_values`.
159
+ def sort_in_memory(records, sort)
160
+ clauses = normalized_sort_clauses(sort)
161
+ return records if clauses.empty?
162
+
163
+ records.each_with_index.sort do |(left, left_index), (right, right_index)|
164
+ compare_clauses(left, right, clauses).nonzero? || (left_index <=> right_index)
165
+ end.map(&:first)
166
+ end
167
+
168
+ def compare_clauses(left, right, clauses)
169
+ clauses.each do |field, ascending|
170
+ comparison = compare_values(left[field], right[field])
171
+ next if comparison.zero?
172
+
173
+ return ascending ? comparison : -comparison
174
+ end
175
+
176
+ 0
177
+ end
178
+
179
+ # Nulls sort last on an ascending order and first on a descending one, the
180
+ # way a database orders them; values `<=>` cannot compare — two booleans,
181
+ # for one — are compared through their string form rather than left
182
+ # undefined, which puts `false` before `true`, again like a database.
183
+ def compare_values(left, right)
184
+ return 0 if left.nil? && right.nil?
185
+ return 1 if left.nil?
186
+ return -1 if right.nil?
187
+
188
+ (left <=> right) || (left.to_s <=> right.to_s)
189
+ end
190
+ end
191
+ end
192
+ end
@@ -0,0 +1,41 @@
1
+ module ForestAdminDatasourcePylon
2
+ module Collections
3
+ class Issue < BaseCollection
4
+ # The allow-list of `POST /issues/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
+ # `param` carries the read-to-filter renames: an issue is read with
21
+ # `type` / `resolution_time` / `latest_message_time` but filtered on
22
+ # `issue_type` / `resolved_at` / `latest_message_activity_at`.
23
+ API_FILTERS = {
24
+ 'state' => { ops: Maps::EQUALITY },
25
+ 'type' => { param: 'issue_type', ops: Maps::EQUALITY.merge(Maps::PRESENCE) },
26
+ 'account_id' => { ops: Maps::EQUALITY.merge(Maps::PRESENCE) },
27
+ 'requester_id' => { ops: Maps::EQUALITY.merge(Maps::PRESENCE) },
28
+ 'assignee_id' => { ops: Maps::EQUALITY.merge(Maps::PRESENCE) },
29
+ 'team_id' => { ops: Maps::EQUALITY },
30
+ 'title' => { ops: Maps::FULL_TEXT },
31
+ 'body_html' => { ops: Maps::FULL_TEXT },
32
+ 'tags' => { ops: Maps::MEMBERSHIP },
33
+ 'created_at' => { ops: Maps::TIME },
34
+ 'updated_at' => { ops: Maps::TIME },
35
+ 'resolution_time' => { param: 'resolved_at', ops: Maps::TIME },
36
+ 'latest_message_time' => { param: 'latest_message_activity_at', ops: Maps::TIME }
37
+ }.freeze
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,88 @@
1
+ module ForestAdminDatasourcePylon
2
+ module Collections
3
+ class Issue < BaseCollection
4
+ # Reading issues named by their id, which Pylon serves one request at a
5
+ # time: `POST /issues/search` cannot filter on id, so the short-circuit
6
+ # `BaseCollection` extracts is answered by `GET /issues/{id}` per id.
7
+ #
8
+ # Split out of the collection for what it costs: everything here exists to
9
+ # bound that fan-out, and none of it is about the shape of an issue.
10
+ module IdLookupReader
11
+ include RecordSerialization
12
+
13
+ private
14
+
15
+ # The records are already narrowed to the ids the filter asked for, so
16
+ # applying the conditions left over by the short-circuit in memory
17
+ # cannot return a record the API would have excluded. The reverse —
18
+ # dropping a record over a condition memory evaluates differently from
19
+ # Pylon — is ruled out by `extract_id_lookup`, which refuses such
20
+ # residuals.
21
+ #
22
+ # Without a residual the ids are the answer, in order, so the window is
23
+ # taken off them before any of them is read: one request per record the
24
+ # caller asked to see, and none for the records it did not. A residual
25
+ # takes that away — which records the window holds is only known once
26
+ # they are all read — so past the cap the selection is refused rather
27
+ # than answered with a fraction of itself.
28
+ def records_by_id(caller, lookup, filter)
29
+ return fetch_by_ids(page_of_ids(lookup.ids, filter)) if lookup.residual.nil?
30
+
31
+ refuse_wide_lookup(lookup.ids.size) if lookup.ids.size > MAX_ID_LOOKUPS
32
+
33
+ page_window(lookup.residual.apply(fetch_by_ids(lookup.ids), self, timezone_for(caller)), filter)
34
+ end
35
+
36
+ # The ids of the window, capped: `MAX_ID_LOOKUPS` bounds a page rather
37
+ # than a selection here, a page being what each of these requests buys.
38
+ def page_of_ids(ids, filter)
39
+ offset, limit = translate_page(filter&.page)
40
+ wanted = limit ? Array(ids[offset, limit]) : ids.drop(offset)
41
+ capped = wanted.first(MAX_ID_LOOKUPS)
42
+ warn_truncated_lookup(wanted.size) if wanted.size > capped.size
43
+
44
+ capped
45
+ end
46
+
47
+ # `GET /issues/{id}` accepts the issue number as well as the UUID, so a
48
+ # record answering with an id other than the one asked for is dropped:
49
+ # see `matches_id?`.
50
+ def fetch_by_ids(ids)
51
+ ids.filter_map do |id|
52
+ record = fetch_issue(id)
53
+ next if record.nil?
54
+
55
+ serialized = serialize(record)
56
+ serialized if matches_id?(serialized, id)
57
+ end
58
+ end
59
+
60
+ # A record the operator can no longer reach — deleted, or outside the
61
+ # token's scope — reads as "no record" rather than as a failed page.
62
+ def fetch_issue(id)
63
+ datasource.client.fetch_issue(id)
64
+ rescue APIError => e
65
+ raise unless e.status == 404
66
+
67
+ nil
68
+ end
69
+
70
+ def warn_truncated_lookup(asked)
71
+ ForestAdminDatasourcePylon.logger.warn(
72
+ "[forest_admin_datasource_pylon] Asked for a page of #{asked} issues by id, reading the first " \
73
+ "#{MAX_ID_LOOKUPS}: Pylon answers one issue per request, and the requests are sequential. " \
74
+ 'Ask for a smaller page to reach the records past this point.'
75
+ )
76
+ end
77
+
78
+ def refuse_wide_lookup(count)
79
+ raise UnsupportedOperatorError,
80
+ "This selection names #{count} issues by id and filters them further, which PylonIssue answers " \
81
+ "with one request per named issue — more than the #{MAX_ID_LOOKUPS} one page covers. Reading " \
82
+ 'only some of them would drop records the other conditions keep, and silently answer a page ' \
83
+ 'that is missing them. Name fewer issues, or drop the other conditions.'
84
+ end
85
+ end
86
+ end
87
+ end
88
+ end