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,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
|