/** * Copyright (c) 2026, Salesforce, Inc., * All rights reserved. * For full license text, see the LICENSE.txt file */ /** * Library API for translating dotted parent-field paths into projection * nodes. Encapsulates two UIAPI conventions that every caller would * otherwise re-implement: * * 1. **Value-wrapper unwrapping.** UIAPI scalar fields are exposed as * object types with a single `value` field (e.g. `Name { value }`). * A caller asking for "Name" gets `Name { value }` — except for * `Id`, which is a real scalar. The structural test for "is this * a wrapper?" lives in `lib/uiapi.ts:isValueWrapperType` so every * caller agrees on the rule. * * 2. **Polymorphic union expansion.** Relationship fields like `Owner` * resolve to a union (`User | Group`). A path like "Owner.Name" * cannot be selected directly; it must be expanded to inline * fragments per union member that has the field. Members lacking * the field are skipped silently — a partial selection is more * useful than a hard failure. * * The MCP intent layer used to carry this logic locally, but it's a * pure function of (schema, path) and other graphiti consumers (CLI * helpers, future surfaces) need the same behavior. */ import { isUnionType, type GraphQLSchema } from "graphql"; import { selectLeaf, type QuerySession } from "./session.js"; import { isValueWrapperType } from "./uiapi.js"; import { getFragmentTargets, MutationContextError, resolvePath } from "./walker.js"; /** * Selects a scalar field at `basePath`, applying UIAPI value-wrapper * unwrapping. `Id` is selected as a leaf directly; value-wrapper types * are selected as `/value`. Falls back to a direct selection * when schema resolution fails — the downstream renderer/validator * will surface a clearer error than we could here. */ function selectScalarOrValue( session: QuerySession, schema: GraphQLSchema, basePath: string[], fieldName: string, ): void { const fullPath = [...basePath, fieldName]; if (fieldName === "Id") { selectLeaf(session, fullPath); return; } try { const wr = resolvePath(schema, session.operation, fullPath); if (wr.isLeaf) { selectLeaf(session, fullPath); return; } if (isValueWrapperType(schema, wr.typeName)) { selectLeaf(session, [...fullPath, "value"]); return; } selectLeaf(session, fullPath); } catch { selectLeaf(session, fullPath); } } /** * Selects a dotted field path (e.g. "Owner.Name") rooted at `basePath`, * automatically expanding any segment that resolves to a polymorphic * union into inline fragments on each member that has the field. * * Behavior contract: * - "Name" (single segment) → calls `selectScalarOrValue`. * - "Account.Name" (no union segments) → walks the dotted path, then * selects the leaf with value-wrapper unwrapping. * - "Owner.Name" where Owner is a union → emits * `Owner { ... on User { Name { value } } ... on Group { Name { value } } }`. * Members that don't have `Name` are skipped silently. * - "Owner.BadField" where no union member has `BadField` → throws. * A silent no-op would render an empty selection; failing loudly * lets the caller correct the field name. * - If the union segment is followed by more path components (e.g. * `Owner.Account.Name`), the remaining path is appended to each * member's inline fragment. */ export function selectDottedFieldPath( session: QuerySession, schema: GraphQLSchema, basePath: string[], dotField: string, ): void { const parts = dotField.split("."); const fieldName = parts.pop(); if (!fieldName) { throw new Error(`Empty field name in dotted path "${dotField}"`); } // Walk parent segments looking for a union; expand the first one we hit. const currentPath = [...basePath]; for (let i = 0; i < parts.length; i++) { currentPath.push(parts[i]!); let resolved; try { resolved = resolvePath(schema, session.operation, currentPath); } catch (err) { // Re-throw mutation-context errors — the walker produces a clear, // actionable message that callers should see rather than a confusing // downstream validation failure. if (err instanceof MutationContextError) { throw err; } // Other path resolution failures (typo, unknown field) — bail out // of union detection and fall back to a flat selection below. break; } if (!isUnionType(resolved.type)) continue; const targets = getFragmentTargets(schema, resolved.type); const remaining = parts.slice(i + 1); let matchedAny = false; for (const target of targets) { const memberPath = [...currentPath, `[${target}]`, ...remaining, fieldName]; try { const fieldResolved = resolvePath(schema, session.operation, memberPath); if (fieldResolved.isLeaf) { selectLeaf(session, memberPath); } else if (isValueWrapperType(schema, fieldResolved.typeName)) { selectLeaf(session, [...memberPath, "value"]); } else { selectLeaf(session, memberPath); } matchedAny = true; } catch { // Field doesn't exist on this union member — skip it. A // partial selection across members that DO have the field // is more useful than failing the whole call. } } if (!matchedAny) { // No union member exposed the requested field. Unlike the // per-member skip above, this means the caller asked for // something nothing can resolve — a silent no-op would // render an empty selection set. Fail loudly so the caller // can correct the field name. throw new Error( `Field "${[...remaining, fieldName].join(".")}" not found on any member of union ` + `${resolved.type.name} (members: ${targets.join(", ")})`, ); } return; } const parentPath = [...basePath, ...parts]; selectScalarOrValue(session, schema, parentPath, fieldName); }