/** * Copyright (c) 2026, Salesforce, Inc., * All rights reserved. * For full license text, see the LICENSE.txt file */ import { buildOutput } from "./build-output.js"; import { getSchemaWithPriming } from "./get-schema-with-priming.js"; import { type AggregateSpec, type AggregationSpec, type GroupByElement, type ToolOutput, } from "./types.js"; import { assertGraphqlName } from "../lib/graphql-name.js"; import { type PrimeDeps } from "../lib/prime-schema.js"; import { addVariable, createSession, createSiblingFieldInstance, deepSetArg, type QuerySession, selectLeaf, } from "../lib/session.js"; import { aggregatePath } from "../lib/uiapi.js"; import { normalizeOrderBy, promoteArg } from "../lib/variable-promotion.js"; const FIELD_REQUIRED_FNS = new Set(["sum", "avg", "min", "max"]); /** * Build a UIAPI aggregate query against a Salesforce org. Implements * `sf_gql_aggregate` intent for the graphiti MCP server (FR-8). * * Implicit behaviors not visible in the signature: * - `aggregations[]` projects each entry under the SObject's aggregate node * using `` as the default GraphQL alias; pass * `alias` to override. Default field is `Id` for `count`/`countDistinct`; * `sum`/`avg`/`min`/`max` require a field (FR-8.3). * - Duplicate result keys (default or explicit) throw — the caller must * disambiguate with aliases (FR-8.4). * - `groupBy` accepts flat field names only in v1; each renders as * `{Field: {group: true}}` and selects `Field { value }` on the * aggregate node so the grouping leaf comes back in the response. * Dotted paths throw. * - `filter` applies to every aggregation in the call. `$varName` leaves * in the filter promote to typed query variables. Per-aggregation * filters are not supported (FR-8.6). * - Operation name defaults to `Aggregate`. * * Throws on invalid `object` or `operationName` (must be valid GraphQL Names), * auth-missing or introspection failure (via `getSchemaWithPriming`), on * FR-8.3/8.4 spec violations, and on dotted groupBy paths. Never throws on * validation or codegen failure (those surface as `warnings[]`). */ export async function buildAggregate(spec: AggregateSpec, deps?: PrimeDeps): Promise { assertGraphqlName(spec.object, "buildAggregate", "object"); const { schema, primingNote, instanceUrl } = await getSchemaWithPriming(spec.org, deps); const session = createSession(spec.org, "aggregate", instanceUrl); session.operationName = spec.operationName ?? `${spec.object}Aggregate`; assertGraphqlName(session.operationName, "buildAggregate", "operationName"); const connectionPath = aggregatePath(spec.object); const aggregateNodePath = [...connectionPath, "edges", "node", "aggregate"]; const groupBy = spec.groupBy ?? []; const extraWarnings: string[] = []; applyGroupBy(session, connectionPath, aggregateNodePath, groupBy); // Declare the reserved cursor variable before promoting user filter/orderBy // variables, so a filter that reuses `$after` keeps its `String` type // (first-wins) and surfaces a collision warning instead of overwriting the // pagination arg's type (W-22697670). Mirrors buildList's ordering. addVariable(session, "after", "String"); deepSetArg(session, connectionPath, "after", [], "$after"); if (spec.filter) { const { rendered } = promoteArg( session, schema, connectionPath, "where", spec.filter, extraWarnings, ); deepSetArg(session, connectionPath, "where", [], rendered); } const orderBy = normalizeOrderBy(spec.orderBy); if (Array.isArray(spec.orderBy) && spec.orderBy.length > 1) { extraWarnings.push( "orderBy: array collapsed to first element. UIAPI accepts a single orderBy object — use multiple keys in one object for multi-field ordering (e.g. { Industry: { order: DESC }, Name: { order: ASC } }).", ); } if (orderBy) { const { rendered } = promoteArg( session, schema, connectionPath, "orderBy", orderBy, extraWarnings, ); deepSetArg(session, connectionPath, "orderBy", [], rendered); } if (spec.first !== undefined) { const { rendered } = promoteArg(session, schema, connectionPath, "first", spec.first); deepSetArg(session, connectionPath, "first", [], rendered); } selectLeaf(session, [...connectionPath, "pageInfo", "hasNextPage"]); selectLeaf(session, [...connectionPath, "pageInfo", "endCursor"]); // FR-8.2 — `aggregate(Account)` with no aggregations and no groupBy // defaults to `count` over `Id` (the SOQL `select count(*)` analogue). const requested = spec.aggregations ?? []; const aggregations: AggregationSpec[] = requested.length === 0 && groupBy.length === 0 ? [{ function: "count" }] : requested; // Pre-seed with groupBy field names: each renders as a top-level response // key on the aggregate node alongside aggregation aliases. An aggregation // alias colliding with a groupBy key produces invalid GraphQL (different // shapes, same response key — spec §5.3.2). const seenKeys = new Set(groupBy.map((el) => (typeof el === "string" ? el : el.field))); for (const agg of aggregations) { if (agg.field === undefined && FIELD_REQUIRED_FNS.has(agg.function)) { throw new Error(`buildAggregate: aggregation '${agg.function}' requires a field (FR-8.3)`); } if (agg.alias !== undefined) { assertGraphqlName(agg.alias, "buildAggregate", "alias"); } const field = agg.field ?? "Id"; // W-23204027: `aggregations[].field` is `z.string()` with no charset at the // schema boundary, so guard it per-builder like `alias`/`groupBy field` — // it flows raw into createSiblingFieldInstance → node.fieldName → rendered // verbatim. The renderField fail-safe backstops this; this is the matching // builder-layer defense-in-depth (parity with W-22735537). assertGraphqlName(field, "buildAggregate", "field"); const key = agg.alias ?? defaultKey(agg.function, field); if (seenKeys.has(key)) { throw new Error( `buildAggregate: duplicate aggregation key '${key}' — set distinct aliases (FR-8.4)`, ); } seenKeys.add(key); applyAggregation(session, aggregateNodePath, agg, field, key); } return buildOutput(session, schema, primingNote, extraWarnings); } function applyGroupBy( session: QuerySession, connectionPath: string[], aggregateNodePath: string[], groupBy: GroupByElement[], ): void { if (groupBy.length === 0) return; const seenGroupByFields = new Set(); for (const element of groupBy) { const field = typeof element === "string" ? element : element.field; if (seenGroupByFields.has(field)) { throw new Error(`buildAggregate: duplicate groupBy field '${field}'`); } seenGroupByFields.add(field); if (field.includes(".")) { throw new Error( `buildAggregate: dotted groupBy field '${field}' is not supported in v1 (flat-only)`, ); } assertGraphqlName(field, "buildAggregate", "groupBy field"); if (typeof element === "string") { deepSetArg(session, connectionPath, "groupBy", [field, "group"], "true"); } else { deepSetArg(session, connectionPath, "groupBy", [field, "function"], element.function); } selectLeaf(session, [...aggregateNodePath, field, "value"]); } } function applyAggregation( session: QuerySession, aggregateNodePath: string[], agg: AggregationSpec, field: string, key: string, ): void { const fieldPath = [...aggregateNodePath, field]; createSiblingFieldInstance(session, fieldPath, key); selectLeaf(session, [...fieldPath, agg.function, "value"]); } function defaultKey(fn: string, field: string): string { return `${fn}${pascalCase(field)}`; } function pascalCase(field: string): string { const stripped = field.endsWith("__c") ? field.slice(0, -3) : field; return stripped .replace(/[^a-zA-Z0-9]/g, " ") .split(/\s+/) .filter(Boolean) .map((w) => w.charAt(0).toUpperCase() + w.slice(1)) .join(""); }