Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -51,15 +51,33 @@ def list(caller, filter = nil, projection = nil)
projection.apply(records)
end

# Only a field registered through `emulate_field_sorting` or
# `replace_field_sorting` becomes sortable here: those are the ones
# `list` above knows how to order, either by emulating the sort over the
# whole collection or by rewriting it into an equivalent one. Every other
# field keeps the flag its datasource declared -- a clause on it is
# handed straight to `child_collection.list`, so marking it sortable
# would let the UI ask for an order nothing honours, and the records
# would come back in whatever order the datasource imposes.
#
# `@sorts` holds nil as the value of an emulated field, so membership is
# read with `key?`, the way `emulated?` reads it.
#
# `CollectionDecorator#schema` only shallow-copies the schema it hands
# over, so the fields hash and the ColumnSchema objects in it are the
# ones of the collection below: both are copied before the flag is set,
# or the decorator would rewrite the schema of its own child.
def refine_schema(child_schema)
child_schema[:fields].each do |name, schema|
if schema.type == 'Column'
schema.is_sortable = true if @sorts[name].nil?
child_schema[:fields][name] = schema
end
schema = child_schema.dup
schema[:fields] = child_schema[:fields].dup

schema[:fields].each do |name, field|
next unless field.type == 'Column' && @sorts.key?(name)

schema[:fields][name] = field.dup.tap { |sortable| sortable.is_sortable = true }
end

child_schema
schema
end

def rewrite_plain_sort_clause(clause)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,32 @@ module Sort
expect { @decorated_book.replace_field_sorting('author_id', nil) }.to raise_error(ForestException, 'A new sorting method should be provided to replace field sorting')
end

# A clause on a field nothing was registered for is handed straight to
# `child_collection.list`, so the flag the datasource declared is the
# truth about it: marking it sortable would let the UI ask for an order
# nothing honours, and the records would come back in the order the
# datasource imposes with no signal that the sort was dropped.
it 'leaves the sortability of the fields it cannot order as the datasource declared it' do
expect(@decorated_book.schema[:fields]['title'].is_sortable).to be false
expect(@decorated_book.schema[:fields]['author_id'].is_sortable).to be false
expect(@decorated_book.schema[:fields]['id'].is_sortable).to be false
end

it 'marks a field sortable once its sorting is replaced' do
@decorated_book.replace_field_sorting('author_id', [{ field: 'id', ascending: true }])

expect(@decorated_book.schema[:fields]['author_id'].is_sortable).to be true
end

# `CollectionDecorator#schema` shallow-copies what it refines, so the
# fields hash and the columns in it belong to the collection below.
it 'leaves the schema of the collection below untouched' do
@decorated_book.emulate_field_sorting('title')

expect(@decorated_book.schema[:fields]['title'].is_sortable).to be true
expect(@collection_book.schema[:fields]['title'].is_sortable).to be false
end

context 'when emulating sort on book.title (no relations)' do
before do
@decorated_book.emulate_field_sorting('title')
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,13 @@
module ForestAdminDatasourcePylon
class Error < StandardError; end
class ConfigurationError < Error; end
class UnsupportedOperatorError < Error; end

# A filter Pylon cannot express. It descends from the toolkit's ValidationError
# rather than from the package's own Error so the agent answers 400 carrying
# the message instead of a 500 "Unexpected error": every one of these names a
# condition the operator set and can change, and the message is the only place
# they learn which one.
class UnsupportedOperatorError < ForestAdminDatasourceToolkit::Exceptions::ValidationError; end

# Raised when a Pylon API call fails. Carries the HTTP status and the
# (parsed) response body so callers — smart actions in particular — can
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
module ForestAdminDatasourcePylon
class Client
# Long by line count only: the public surface is one explicit method per Pylon
# endpoint, each delegating to the shared helpers below.
class Client # rubocop:disable Metrics/ClassLength
MAX_SEARCH_LIMIT = 1000

# `next_cursor` is nil as soon as Pylon stops advertising a next page, so
Expand All @@ -19,22 +21,90 @@ def me
# POST /issues/search accepts an empty body and then returns the most recent
# issues, ordered by `created_at` descending.
def search_issues(limit:, cursor: nil, filter: nil, search_text: nil)
search_resource('issues/search', limit: limit, cursor: cursor, filter: filter, search_text: search_text)
end

# Accepts either the UUID or the issue number.
def fetch_issue(id)
fetch_resource('issues', id)
end

def search_accounts(limit:, cursor: nil, filter: nil, search_text: nil)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Function with many parameters (count = 4): search_accounts [qlty:function-parameters]

search_resource('accounts/search', limit: limit, cursor: cursor, filter: filter, search_text: search_text)
end

def list_accounts(limit:, cursor: nil)
list_resource('accounts', limit: limit, cursor: cursor)
end

# Accepts either the Pylon UUID or the account's external id.
def fetch_account(id)
fetch_resource('accounts', id)
end

def search_contacts(limit:, cursor: nil, filter: nil, search_text: nil)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Function with many parameters (count = 4): search_contacts [qlty:function-parameters]

search_resource('contacts/search', limit: limit, cursor: cursor, filter: filter, search_text: search_text)
end

# GET /contacts is paginated exactly like GET /accounts even though the
# OpenAPI spec forgets to document its query parameters.
def list_contacts(limit:, cursor: nil)
list_resource('contacts', limit: limit, cursor: cursor)
end

def fetch_contact(id)
fetch_resource('contacts', id)
end

# GET /users is unpaginated. Deactivated agents are included by default so
# that assignees of older issues stay resolvable.
def fetch_users(include_deactivated: true)
fetch_all('users', 'include_deactivated' => include_deactivated)
end

def fetch_user(id)
fetch_resource('users', id)
end

# GET /teams is unpaginated and takes no parameter.
def fetch_teams
fetch_all('teams')
end

def fetch_team(id)
fetch_resource('teams', id)
end

private

def search_resource(path, limit:, cursor: nil, filter: nil, search_text: nil)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Function with many parameters (count = 5): search_resource [qlty:function-parameters]

body = { 'limit' => clamp_limit(limit) }
body['cursor'] = cursor unless blank?(cursor)
body['filter'] = filter unless filter.nil?
body['search_text'] = search_text unless blank?(search_text)

must_succeed('issues/search') { to_search_page(connection.post('issues/search', body).body) }
must_succeed(path) { to_search_page(connection.post(path, body).body) }
end

# Accepts either the UUID or the issue number. The id comes from
# operator-supplied filter values, so it is escaped before joining the path.
def fetch_issue(id)
path = "issues/#{Faraday::Utils.escape(id)}"
must_succeed(path) { extract_data(connection.get(path).body) }
# `limit` is mandatory on the paginated GET endpoints, unlike their POST
# /search counterparts which default it server-side.
def list_resource(path, limit:, cursor: nil)
params = { 'limit' => clamp_limit(limit) }
params['cursor'] = cursor unless blank?(cursor)

must_succeed(path) { to_search_page(connection.get(path, params).body) }
end

private
def fetch_all(path, params = {})
must_succeed(path) { Array(extract_data(connection.get(path, params).body)) }
end

# The id comes from operator-supplied filter values, so it is escaped before
# being joined to the path.
def fetch_resource(resource, id)
path = "#{resource}/#{Faraday::Utils.escape(id)}"
must_succeed(path) { extract_data(connection.get(path).body) }
end

def clamp_limit(limit)
value = limit.to_i
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
module ForestAdminDatasourcePylon
module Collections
class Account < CursorCollection
include SchemaDefinition
include Serializer

def initialize(datasource, custom_fields: [])
super(datasource, 'PylonAccount', custom_fields: custom_fields, searchable: true)
end

protected

def filter_table = ApiFilters

def unsortable_warning
'[forest_admin_datasource_pylon] PylonAccount cannot honour the requested order; neither GET /accounts ' \
'nor POST /accounts/search takes a sort parameter, so accounts come back in the order the API imposes.'
end

def search_page(limit:, cursor:, filter:, search_text:)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Function with many parameters (count = 4): search_page [qlty:function-parameters]

datasource.client.search_accounts(limit: limit, cursor: cursor, filter: filter, search_text: search_text)
end

def list_page(limit:, cursor:)
datasource.client.list_accounts(limit: limit, cursor: cursor)
end

def fetch_one(id)
datasource.client.fetch_account(id)
end
end
end
end

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Found 34 lines of similar code in 2 locations (mass = 114) [qlty:similar-code]

Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
module ForestAdminDatasourcePylon
module Collections
class Account < CursorCollection
# The allow-list of `POST /accounts/search`, transcribed from the API
# reference: a field absent from this table cannot be filtered at all, and
# an operator absent from a field's map is rejected by Pylon.
#
# It is the single source of truth for filtering — `define_schema` derives
# every column's `filter_operators` from it, so this collection declares no
# filter the translator would then refuse; the absence family the agent
# derives on top of it is the exception `Query::OperatorMaps::Table`
# describes.
module ApiFilters
Maps = Query::OperatorMaps

extend Maps::Table

CUSTOM_FIELD_OPS = Maps::CUSTOM_FIELD_OPS

# `id` is filtered server-side here, which is what spares this
# collection the primary-key short-circuit Issue needs.
#
# `name` gets SUBSTRING rather than FULL_TEXT: the endpoint accepts
# `string_contains` but no negation of it. `external_ids` is left out
# entirely although the endpoint filters it — the API matches the bare
# external-id strings while the column shows `{external_id, label}`
# objects, so the filter would run on something the operator cannot see.
# The account read endpoint accepts an external id in place of the
# primary key, which is the way to reach a record by one.
#
# No time field is filterable: `created_at`, `updated_at` and
# `latest_customer_activity_time` are absent from the allow-list.
API_FILTERS = {
'id' => { ops: Maps::EQUALITY },
'name' => { ops: Maps::EQUALITY.merge(Maps::SUBSTRING) },
'domains' => { ops: Maps::MEMBERSHIP },
'tags' => { ops: Maps::MEMBERSHIP },
'owner_id' => { ops: Maps::EQUALITY.merge(Maps::PRESENCE) }
}.freeze
end
end
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
module ForestAdminDatasourcePylon
module Collections
class Account < CursorCollection
# Every column is read-only in this story: writes land in a later one. No
# column is sortable either — neither `GET /accounts` nor
# `POST /accounts/search` exposes a sort parameter, so advertising a
# sortable column would let the UI ask for an order the API cannot honour.
#
# Filter operators are not chosen here: they come from
# `ApiFilters::API_FILTERS`, which mirrors the allow-list of the API. A
# column missing from that table gets no operator, so the UI offers no
# filter of this collection's own that Pylon would refuse — the absence
# family the agent derives above the datasource being the exception
# `Query::OperatorMaps::Table` describes.
module SchemaDefinition
OneToManySchema = BaseCollection::OneToManySchema

private

def define_schema
define_identity_fields
define_domain_fields
define_ownership_fields
define_integration_fields
define_time_fields
end

# The reverse sides of the two ManyToOne relations pointing here. Both
# `/issues/search` and `/contacts/search` filter `account_id`
# server-side, so a related list is one request and no in-memory pass.
#
# `owner_id` stays a plain column: it does point at a PylonUser, and the
# embedder would resolve it like any other key, but nothing in the panel
# asks for the owner of an account yet.
def define_relations
add_field('issues', OneToManySchema.new(foreign_collection: 'PylonIssue',
origin_key: 'account_id', origin_key_target: 'id'))
add_field('contacts', OneToManySchema.new(foreign_collection: 'PylonContact',
origin_key: 'account_id', origin_key_target: 'id'))
end

def define_identity_fields
add_column('id', 'String', is_primary_key: true)
add_column('name', 'String')
# Left as String rather than Enum: Pylon ships customer / partner /
# prospect but lets an organization define its own account types.
add_column('type', 'String')
add_column('is_disabled', 'Boolean')
end

# `domain` and `primary_domain` carry the same value; both are kept
# because Pylon returns both, and only the `domains` list is filterable.
def define_domain_fields
add_column('domain', 'String')
add_column('primary_domain', 'String')
add_column('domains', 'Json')
add_column('tags', 'Json')
end

def define_ownership_fields
# Flattened from the nested `{ id: ..., email: ... }` object Pylon
# returns; a plain column, see `define_relations` above.
add_column('owner_id', 'String')
add_column('external_ids', 'Json')
end

def define_integration_fields
add_column('channels', 'Json')
add_column('crm_settings', 'Json')
end

def define_time_fields
%w[created_at updated_at latest_customer_activity_time].each { |field| add_column(field, 'Date') }
end
end
end
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
module ForestAdminDatasourcePylon
module Collections
class Account < CursorCollection
module Serializer
NATIVE_FIELDS = %w[id name type is_disabled domain primary_domain domains tags external_ids
channels crm_settings created_at updated_at
latest_customer_activity_time].freeze

private

def serialize(account)
attrs = account.is_a?(Hash) ? account : {}
record = NATIVE_FIELDS.to_h { |field| [field, attrs[field]] }
record['owner_id'] = nested_id(attrs['owner'])
add_custom_field_values(record, attrs['custom_fields'])
record
end
end
end
end
end
Loading
Loading