Skip to content

feat(datasource-pylon): all collections + relations (EXT-8) - #354

Merged
christophebrun-forest merged 9 commits into
feat/datasource-pylonfrom
ext-8-collections-relations
Aug 18, 2026
Merged

feat(datasource-pylon): all collections + relations (EXT-8)#354
christophebrun-forest merged 9 commits into
feat/datasource-pylonfrom
ext-8-collections-relations

Conversation

@christophebrun-forest

@christophebrun-forest christophebrun-forest commented Aug 12, 2026

Copy link
Copy Markdown
Member

Story

EXT-8 — Story 4: All collections + relations

Adds the four remaining read-only collections and wires the relational graph between issues, accounts, contacts, users and teams.

What's inside

Client — search/list/fetch endpoints for the four resources, mutualized in generic private helpers (search_issues/fetch_issue now delegate to them, behavior unchanged).

Collections

  • PylonAccount, PylonContact (CursorCollection): cursor-paginated listing (GET, 60 req/min) when browsing, POST /search (20 req/min) as soon as a filter or free-text search is present, and a single-id fast path on the record endpoint for detail views. Their search endpoints filter id server-side, so unlike issues they need no primary-key short-circuit: id sits in api_filters and translates like any other field, or included.
  • PylonUser, PylonTeam (FetchAllCollection): GET /users / GET /teams return the complete dataset, so filtering, sorting and pagination run in memory — correct because the snapshot is complete, and every advertised operator is proven in-memory-evaluable through the toolkit's equivalence machinery (guarded by a programmatic spec). Deactivated agents are included so historical assignees stay resolvable.

Relationsissue → account / requester (contact) / assignee (user) / team (ManyToOne) with their OneToMany inverses, plus contact → account and account → contacts. All reverse sides ride the existing server-side filters (account_id, requester_id, assignee_id, team_id).

RelationEmbedder — schema-driven port of the Zendesk pattern: ManyToOne projections are resolved in bulk at read time (one id in [...] search per foreign collection per page for accounts/contacts, chunked at 100; one indexed fetch-all for users/teams), reusing each foreign collection's own serializer instead of duplicating field lists. A relation the projection does not ask for costs zero requests.

Filter allow-lists are transcribed from the Pylon API reference (static.usepylon.com/openapi.json); fields the API cannot filter advertise no operator, and external_ids advertises none because the API matches bare id strings while the column displays {external_id, label} objects.

Tests

  • 430 examples, 0 failures
  • Line coverage 100% (877/877), branch 93.31%
  • RuboCop: 0 offenses

🤖 Generated with Claude Code

Note

Add PylonAccount, PylonContact, PylonUser, and PylonTeam collections with ManyToOne relation support

  • Registers four new collections (PylonAccount, PylonContact, PylonUser, PylonTeam) in the datasource alongside the existing PylonIssue collection.
  • Adds CursorCollection and FetchAllCollection base classes to handle cursor-paginated (Account, Contact) and full-dataset (User, Team) retrieval strategies with in-memory filtering, sorting, and pagination.
  • Introduces RecordSerialization and RelationEmbedder mixins to flatten nested foreign-key objects and embed ManyToOne related records into list responses.
  • Adds with_resolved_relations to BaseCollection to rewrite relation field filters into local foreign-key IN conditions, short-circuiting to empty results when no foreign records match.
  • Extends the Pylon client with endpoint methods for accounts, contacts, users, and teams (search, list, fetch).
  • Adds shared OperatorMaps module and per-collection ApiFilters to centralize server-side filter operator declarations.
  • UnsupportedOperatorError now inherits from ValidationError to surface as HTTP 400.
  • Risk: relation filter resolution issues up to MAX_RELATION_KEYS (500) foreign reads per filter; queries matching more than 500 foreign records raise UnsupportedOperatorError.

Macroscope summarized 3027ce6.

christophebrun-forest and others added 5 commits August 12, 2026 18:28
Adds search/list/fetch methods for the four new resources, routed
through shared private helpers; search_issues and fetch_issue now
delegate to the same helpers with unchanged behavior.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Extracts the cursor-walk search flow, sort warning and operator maps
into BaseCollection and shared modules; declares the four ManyToOne
relations on PylonIssue and adds the schema-driven RelationEmbedder.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Cursor-paginated read-only collections with server-side filters,
free-text search, a single-id fast path on the record endpoint and
their relations to issues, contacts and account.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Fetch-all read-only collections: the endpoints return the complete
dataset, so filtering, sorting and pagination run in memory over
operators proven evaluable by the toolkit equivalence machinery.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Registers the five collections on the datasource and covers the
relation embedding end to end: bulk id search per foreign collection,
chunking, dedup and missing-record handling.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@linear-code

linear-code Bot commented Aug 12, 2026

Copy link
Copy Markdown

EXT-8

@qltysh

qltysh Bot commented Aug 12, 2026

Copy link
Copy Markdown

19 new issues

Tool Category Rule Count
qlty Structure Function with many parameters (count = 4): search_accounts 10
qlty Structure Function with high complexity (count = 6): fetch_records 6
qlty Duplication Found 34 lines of similar code in 2 locations (mass = 114) 2
qlty Structure High total complexity (count = 59) 1

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]

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]


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]

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]

'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]

'nor POST /contacts/search takes a sort parameter, so contacts 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]

return ascending ? comparison : -comparison
end

0

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 high complexity (count = 5): compare_clauses [qlty:function-complexity]

return 1 if left.nil?
return -1 if right.nil?

(left <=> right) || (left.to_s <=> right.to_s)

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 high complexity (count = 5): compare_values [qlty:function-complexity]

'POST /issues/search always returns issues from the most recent to the oldest.'
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]

# not it resolved: a null foreign key, and a record the operator can no
# longer reach, both read as "no related record" rather than as a row
# missing the field.
def embed_foreign(foreign_collection, relations, records, rows)

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): embed_foreign [qlty:function-parameters]

- refuse an absence filter Pylon cannot answer, with a message
  naming it, and map missing onto is_unset where the API takes it
- report a refused filter as a ValidationError, so the agent
  answers 400 with the message rather than a 500 "Unexpected error"
- refuse a filter on a related field, naming the key to use instead
- drop a record answered under an alias of the primary key
- declare no column groupable, and answer an aggregation in memory
  on the two collections holding every record Pylon has
- take no custom field on those two, where nothing reads one
- leave a blank foreign key out of the relation reads, like a null
- serialize only the records a relation asked for
- hoist filter_table, api_filters and add_column into BaseCollection

Also fixes SortCollectionDecorator#refine_schema, which marked every
column of every datasource sortable instead of the ones a sort was
registered for, and rewrote the schema of its child while doing so.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
#
# FetchAllCollection, which does hold every record Pylon has, overrides
# this and answers exactly.
def aggregate(_caller, _filter, _aggregation, _limit = 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): aggregate [qlty:function-parameters]

rescue APIError => e
raise unless e.status == 404

[]

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 high complexity (count = 5): records_by_id [qlty:function-complexity]

#
# The rows are keyed with strings because that is how the agent reads
# them, while `Aggregation#apply` hands them back keyed with symbols.
def aggregate(caller, filter, aggregation, limit = 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): aggregate [qlty:function-parameters]


serialized = serialize(record)
serialized if matches_id?(serialized, id)
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.

Function with high complexity (count = 6): fetch_by_ids [qlty:function-complexity]

page_window no longer slices when the filter carries no page, or a page
naming no limit. FetchAllCollection was answering a page-less read with the
first 1000 records of a larger set, which left SortCollectionDecorator
without a position for the rest.

domains and tags no longer advertise contains / not_contains: the columns are
typed Json, on which the toolkit refuses every substring operator, so the UI
offered a filter that could only error. Typing them as a String array is not
a way out either, the validator has no branch for an array column type.

A relation:field leaf is resolved into the foreign_key in [...] the endpoint
filters, by reading the foreign collection for the keys of the records
matching it, instead of being refused while the schema advertises the
relation as filterable. Bounded at 500 keys and refused rather than truncated
past that, and answered with no record and no request when no foreign record
matched. Resolved at the entry of the read, so the primary-key lookup route
benefits too and the tree is walked once instead of twice.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
next page_window(records_by_id(id), query) if id

search_records(caller, query)
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.

Function with high complexity (count = 6): fetch_records [qlty:function-complexity]

page_window stopped slicing a page-less read in cee51dd, which fixed
FetchAllCollection, where the records are already in hand. The cursor
collections take theirs from the walk, and the walk was still bounded:
translate_page answered a missing page with MAX_SEARCH_LIMIT, so a read
asking for every record travelled as a read asking for a thousand. The
walker could not tell the two apart, broke out on records.size >= needed
before reaching its cap check, and answered a larger set with its first
thousand records without logging a truncation.

translate_page now answers a missing page, or a page naming no positive
limit, with a nil limit, and walk reads nil as "every record past the
offset": it follows the cursor until Pylon reports no page left or a cap
stops it, and a cap stopping it is a truncation the warning now names.
batch_size bounds itself on the record budget alone when there is no
window, and the final slice becomes a drop.

A window the caller did ask for keeps its exact cost and its silence.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
end

records[offset.to_i, limit.to_i] || []
limit ? (records[offset, limit] || []) : records.drop(offset)

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 high complexity (count = 14): walk [qlty:function-complexity]

Brings 28d4da6 in: the gem registered in .releaserc.js and the
activesupport runtime dependency. Disjoint from the files this branch
touches, so the merge carries no conflict.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@christophebrun-forest
christophebrun-forest merged commit 4b14086 into feat/datasource-pylon Aug 18, 2026
52 checks passed
@christophebrun-forest
christophebrun-forest deleted the ext-8-collections-relations branch August 18, 2026 14:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant