Skip to content

fix(data-fabric): multi-join queries were silently no-ops — route to the multi-entity endpoint with the correct wire contract#591

Open
aayushuipath wants to merge 6 commits into
mainfrom
fix/multi-join-wire-contract
Open

fix(data-fabric): multi-join queries were silently no-ops — route to the multi-entity endpoint with the correct wire contract#591
aayushuipath wants to merge 6 commits into
mainfrom
fix/multi-join-wire-contract

Conversation

@aayushuipath

@aayushuipath aayushuipath commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

Summary

Fixes the multi-join support shipped in #543 (v1.5.2), which never performed a join: the SDK posted joins to the ID-based query route, where the backend does not implement the multi-entity contract and silently drops the unknown body key — every join query returned 200 with plausible base-entity rows while performing no join.

Live evidence (prod + alpha, identical behavior):

  • ID-based entity/{id}/query with a join to a nonexistent entity → 200, base rows (the joins key is never parsed)
  • Name-based /{EntityName}/query with the SDK's shape → 400 'joins[0].type' is required.
  • Name-based route with the documented contract → joins execute, with deep validation (join-key type checks, unknown-entity rejection)

The actual contract is the Multi-Entity Query API: POST /api/EntityService/{EntityName}/query with { type: "LEFT"|"INNER", entity, on: { left, right } } and entity-qualified field references; result rows come back with entity-qualified keys.

What changed

  • Public API unchanged: EntityJoin keeps its released shape; existing user code starts working. The SDK translates it to the wire contract internally (joinTypetype, relatedEntityNameentity, join keys → qualified on.left/on.right, defaulting the base entity from the queried entity's name).
  • Endpoint routing: when joins is present, queryRecordsById resolves the entity name (one getById metadata call, folderKey passthrough) and posts to the name-based route. No-joins queries are byte-for-byte unchanged (ID route, no metadata lookup).
  • JoinType.InnerJoin added (contract supports INNER and LEFT; mixed types rejected by the API).
  • Client-side projection validation: the API rejects join queries without a projection (Fields or Aggregates required), so the SDK now throws an actionable ValidationError when joins is supplied without selectedFields/aggregates.
  • Pagination with joins works through the existing body start/limit path (the name-based route ignores URL pagination params; verified cursor paging across pages live).

Why the tests didn't catch it in #543 — and what changed

The join integration test asserted the response envelope (no 4xx, items array, Id present, valid pagination) — all of which a backend that ignores joins satisfies. The tests now assert the join effect:

  • LEFT join: at least one row carries a related-entity-qualified key ("sdkJoinRelated.*") — fails when the backend ignores the clause
  • INNER join: non-empty, ⊆ LEFT row count, every row carries related-entity keys
  • Negative control: a join to a nonexistent entity must reject — if it resolves, the SDK is back on an endpoint that ignores joins
  • Unit tests assert the translated wire body, endpoint routing, qualification defaults/no-double-qualify, folderKey passthrough, and the no-joins regression path

Behavior notes

  • Join-query result rows now use entity-qualified keys ("Customer.Name") — this is the multi-entity API's response format (previously joins returned unqualified base rows only, i.e. the feature didn't work, so no working code depended on the old shape)
  • A LEFT join with no match omits the related entity's keys from the row (not null)
  • Joins are not supported on choice-set, relationship, file, encrypted, or system fields (API limitation, documented on EntityJoin)
  • One extra metadata GET per join query (entity-name resolution)
  • Folder-scoped entities are not supported by the multi-entity query API (verified live: the same folder-scoped entity reads fine through the ID-based route with the folder header, but the name-based route returns 403 User lacks READ permission — the joins authz path is not folder-aware server-side). Documented on EntityJoin; the server's 403 is surfaced as-is.

Test plan

  • npm run typecheck — clean
  • npm run lint — 0 warnings, 0 errors
  • npx vitest run tests/unit — 2065 tests pass (8 new/updated join tests)
  • npm run build — succeeds
  • Live smoke against a Data Fabric tenant (prod) — 10/10 checks pass: chained 2-join LEFT query returns related-entity fields end-to-end, INNER join, nonexistent-entity join rejects server-side (Entity NoSuchXYZ does not exist), missing-projection throws client-side, paginated join (pageSize 1 + cursor to page 2), no-joins filter query unchanged, getRecordById unchanged

Follow-up

The Python SDK (UiPath/uipath-python#1616) has the same defect — same ID-based route, same non-contract shape — and needs the equivalent fix.

🤖 Generated with Claude Code

@aayushuipath
aayushuipath requested a review from a team July 9, 2026 17:11
// and translate each join to the wire shape ({ type, entity, on: { left, right } }).
let getEndpoint = () => DATA_FABRIC_ENDPOINTS.ENTITY.QUERY_BY_ID(id);
if (options?.joins && options.joins.length > 0) {
const { name: baseEntityName } = await this.getById(id, folderKey !== undefined ? { folderKey } : undefined);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Double-telemetry violation per conventions.md — the "Delegation anti-pattern":

queryRecordsById is decorated with @track('Entities.QueryRecordsById'). Calling this.getById() here fires its own @track('Entities.GetById') decorator, so every join query emits two telemetry events — one for the query and a spurious one for the internal entity-name lookup.

"Delegation anti-pattern: when a public service method delegates to another service's public @track-decorated method, both @track decorators fire, causing double telemetry. Fix: extract the shared logic into an internal helper function (no @track) and have both public service methods call the helper directly."

The fix is a private helper that makes the HTTP call directly without the decorator:

/** Resolves the entity name for a given entity ID (no telemetry — internal use only). */
private async resolveEntityName(id: string, folderKey?: string): Promise<string> {
  const response = await this.get<RawEntityGetResponse>(
    DATA_FABRIC_ENDPOINTS.ENTITY.GET_BY_ID(id),
    { headers: createHeaders({ [FOLDER_KEY]: folderKey }) },
  );
  const metadata = transformData(response.data as RawEntityGetResponse, EntityMap);
  return metadata.name as string;
}

Then in queryRecordsById:

const baseEntityName = await this.resolveEntityName(id, folderKey);

And getById continues to call its own (full) internal pipeline unchanged.

@claude

claude Bot commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

✅ No issues found. Checked for bugs and CLAUDE.md compliance.

…ct for joins

The joins support added in #543 posted the EntityJoin shape to the ID-based
query route, which does not implement the multi-entity contract - the server
silently drops the unknown joins body key and returns base-entity rows, so
every join query 200s while performing no join (verified live: a join to a
nonexistent entity returned 200).

The multi-entity contract lives on the name-based route
(POST /api/EntityService/{EntityName}/query) and expects
{ type: LEFT|INNER, entity, on: { left, right } } with entity-qualified
field references.

- Keep the public EntityJoin shape unchanged; translate it to the wire
  contract internally and route join queries to the name-based endpoint
  (entity name resolved via getById, folderKey passthrough).
- Add JoinType.InnerJoin (contract supports INNER and LEFT).
- Validate client-side that join queries carry selectedFields or aggregates
  (the API rejects joins without a projection).
- Document that folder-scoped entities are not supported by the multi-entity
  query API (verified live: ID-based reads work with the folder header, the
  name-based route 403s - the joins authz path is not folder-aware).
- Unit tests assert the translated wire body and endpoint routing, plus the
  no-joins regression path (ID endpoint, no metadata lookup).
- Integration tests assert the join EFFECT (qualified related-entity keys in
  rows, INNER within LEFT) and a negative control (join to a nonexistent
  entity must reject) - envelope-only assertions cannot fail when the
  backend ignores the joins clause, which is how the original wiring shipped.

Verified live against a Data Fabric tenant: chained 2-join LEFT query returns
related-entity fields, INNER join works, pagination (body start/limit) works,
and no-joins queries are unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@aayushuipath
aayushuipath force-pushed the fix/multi-join-wire-contract branch from d67d5e6 to f3aa69b Compare July 9, 2026 17:44
vi.mocked(PaginationHelpers.getAll).mockResolvedValue({ items: [], totalCount: 0 });
const getByIdSpy = vi
.spyOn(entityService, "getById")
.mockResolvedValue({ name: "Order" } as any);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

as any appears 7 times across the new join tests (lines 1717, 1755, 1780, 1804, 1831, 1867, 1928), violating the no-any rule from conventions.md:

"No any type — use unknown if truly unknown, then validate."

The root cause is that getById returns EntityGetResponse (= RawEntityGetResponse & EntityMethods), and the tests only need { name } from that shape, making a full typed mock awkward.

The cleanest fix that stays within the conventions is a scoped helper at the top of the queryRecordsById describe block:

const mockGetByIdName = (name: string) =>
  vi.spyOn(entityService, "getById").mockImplementation(
    () => Promise.resolve(createEntityWithMethods({ name } as RawEntityGetResponse, entityService))
  );

Alternatively, since the only consumed field is name, define a local interface and use a cast that's honest about what it omits:

// Cast is intentional: queryRecordsById only reads `.name` from getById's result.
vi.spyOn(entityService, "getById").mockResolvedValue(
  { name: "Order" } as Pick<EntityGetResponse, "name"> as unknown as EntityGetResponse
);

Either way, 7 identical inline casts should be collapsed to one shared setup rather than repeated in every test.

@claude

claude Bot commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

Review summary

New finding posted this run:

  • tests/unit/services/data-fabric/entities.test.ts line 1717 — as any appears 7 times across the new join tests (1717, 1755, 1780, 1804, 1831, 1867, 1928), violating the no-any convention. Suggested consolidating into a shared typed helper.

Still-open thread from a previous run (not re-raised):

  • src/services/data-fabric/entities.ts line 709 — double-telemetry delegation anti-pattern: queryRecordsById (@track) calls this.getById() (@track), emitting two telemetry events per join query. Thread remains unresolved.

…ed helper

Collapses seven inline '{ name } as any' casts into a single mockGetByIdName
helper with a direct EntityGetResponse assertion, per the no-any convention
(review feedback).

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

claude Bot commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

✅ No issues found. Checked for bugs and CLAUDE.md compliance.

* several composes a multi-entity (multi-join) query. Up to 3 joins are
* supported (the SDK throws a `ValidationError` for more), and all of them
* must share the same {@link JoinType}.
* must share the same {@link JoinType} (the API rejects mixed join types).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

no need to add API behaviour

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Dropped in 33ae90f — the parenthetical is gone; "must share the same JoinType" carries it.

* must share the same {@link JoinType}.
* must share the same {@link JoinType} (the API rejects mixed join types).
*
* Join queries require `selectedFields` or `aggregates`. Multi-entity result

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

what are selectedFields and aggregates? are they fields within a type? probably we can simplify this comment for ease of understanding

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Reworded in 33ae90f — the sentence now says a join query "must also set selectedFields or aggregates on {@link EntityQueryRecordsOptions}", so it's clear they're sibling query options, and the surrounding prose is shorter.

*
* // Multi-join: pull fields from related entities into the query
* // Multi-join: pull fields from related entities into the query.
* // Reference fields as "EntityName.Field" wherever the name is ambiguous

@ninja-shreyash ninja-shreyash Jul 14, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

do we need this info at method level? we already have the info at "EntityJoin" type JSDoc?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Agreed — trimmed in 33ae90f. The method-level example comment is now one line pointing at EntityJoin (in all three JSDoc copies: queryRecordsById in the service + models, and queryRecords), so the type doc is the single source for join semantics.

- Drop the mixed-join-types parenthetical (the JoinType sentence covers it).
- Point selectedFields/aggregates at EntityQueryRecordsOptions so it is clear
  they are query options, and compress the folder-scope note.
- De-duplicate method-level example commentary; EntityJoin is the single
  source for join semantics.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* });
*
* // Multi-join: pull fields from related entities into the query
* // (result rows use entity-qualified keys — see EntityJoin).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

have we defined qualified/non-qualified anywhere? We should make it explicit if not already (since its not obvious)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good catch — the terms were shown by example but never defined. Done in f127aa88: the EntityJoin JSDoc (the place both call-site comments link to) now spells it out — qualified = entity-name-dot-prefixed ("Customer.Name"), unqualified = bare field name ("Name"), with the resolution rule (unqualified only works while the name is unique across all joined entities).

aayushuipath and others added 3 commits July 16, 2026 16:28
Actions runs were never enqueued for the previous push.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…n EntityJoin JSDoc

Address PR review: the terms were used but never defined — spell out
that qualified = entity-name-dot-prefixed, unqualified = bare name, and
when each resolves.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Resolve conflict in src/services/data-fabric/entities.ts: main's #594 removed
class-level JSDoc (now inherited from EntityServiceModel); the interface already
carries this PR's multi-join docs, so the duplicated class block is dropped.

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

claude Bot commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

✅ No issues found. Checked for bugs and CLAUDE.md compliance.

@sonarqubecloud

Copy link
Copy Markdown

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.

2 participants