# Hand-authoring Salesforce GraphQL queries & mutations (fallback) **Use this only when the graphiti CLI is genuinely unreachable** — `@salesforce/graphiti` isn't installed, or the org can't be primed. When the CLI *is* available it authors the query for you with these same guardrails already applied; see [graphiti-cli.md](graphiti-cli.md). This doc is the hand-authoring path: the schema-grep lookup plus the document templates and platform rules you'd otherwise lean on the CLI to apply. These rules are independent of the SDK reshape — only the *call mechanics* changed (see [sdk-api.md](sdk-api.md)). Verify every entity and field via `bash /scripts/graphql-search.sh ` before writing a query (`` = wherever this skill is installed). ## Schema lookup (do this first) Map intent to PascalCase ("accounts" → `Account`), then run the search script from the SFDX project root (where `schema.graphql` lives). The script reads `./schema.graphql` and does **not** walk up the tree — if the schema is elsewhere, pass `--schema ` (or set `GRAPHQL_SCHEMA`). It prints the resolved schema path on stderr; confirm it's the right one. ```bash bash /scripts/graphql-search.sh Account bash /scripts/graphql-search.sh Account Contact Opportunity # multiple bash /scripts/graphql-search.sh --schema path/to/schema.graphql Account # schema not at ./ ``` Output sections per entity: (1) Type definition, (2) Filter options, (3) Sort options, (4) Create wrapper `CreateInput`, (5) Create fields `CreateRepresentation`, (6) Update wrapper `UpdateInput`, (7) Update fields `UpdateRepresentation`. If an entity isn't found: try `__c`/`__e`, try a `_Record` suffix (v60+); if it's still unresolved it may not be deployed — **ask the user**. Introspect nested references iteratively as you discover them; if the lookups aren't converging on what you need, ask the user rather than keep guessing. Never generate a query with an unconfirmed entity or field. **Never open *or edit* `schema.graphql`** (265K+ lines) — no cat, less, head, tail, editors, or programmatic parsers. It is a generated, read-only mirror of the org; editing it (e.g. to add a field that won't resolve) silences the validator but grants no org access, so the operation still fails at runtime. To change what it contains, deploy metadata then regenerate (`npm run graphql:schema`). ## Read query template ```graphql query QueryName($after: String) { uiapi { query { EntityName( first: 10 after: $after where: { ... } orderBy: { ... } ) { edges { node { Id FieldName @optional { value } # Parent relationship (non-polymorphic) — @optional on the relationship AND its fields Owner @optional { Name @optional { value } } # Parent relationship (polymorphic — use fragments) What @optional { ...WhatAccount ...WhatOpportunity } # Child relationship — max 1 level, no grandchildren. # `first:` is a FIELD ARGUMENT (parens on the field); `@optional` is a # bare directive that takes no arguments. Keep them separate. Contacts(first: 10) @optional { edges { node { Name @optional { value } } } } } } pageInfo { hasNextPage endCursor } } } } } fragment WhatAccount on Account { Id Name @optional { value } } fragment WhatOpportunity on Opportunity { Id Name @optional { value } } ``` Consuming code must defend against omitted (FLS-stripped) fields: ```typescript const name = node.Name?.value ?? ""; const relatedName = node.Owner?.Name?.value ?? "N/A"; ``` This is exactly how the shipped `accounts.ts` `toAccount()` mapper works — `node.Name?.value ?? "Unknown"`, `node.Industry?.value ?? null`, etc. ## `@optional` and FLS Salesforce field-level security makes a query fail **entirely** if the user lacks access to even one selected field. The `@optional` directive (v65+) tells the server to omit inaccessible fields instead of failing the whole request. `@optional` is a per-field directive (`directive @optional on FIELD`), so apply it at **every level of nesting**, not just the outermost: decorate each scalar field, each parent relationship, **and the nested fields inside that relationship** — `Owner @optional { Name @optional { value } }`, not `Owner @optional { Name { value } }`. The template above does this throughout. > Shipped code varies: `userProfileApi.ts` uses `FirstName @optional { value }`, while the > `accounts.ts` demo selects bare `Name { value }` and leans entirely on defensive > `?.value ?? fallback` downstream. Bare selection is only safe when every selected field is > guaranteed-accessible; **decorate with `@optional` by default** so a single FLS-restricted > field can't fail the whole query. Always pair it with `?.`/`??` in consuming code regardless. ## Filtering ```graphql # Implicit AND Account(where: { Industry: { eq: "Technology" }, AnnualRevenue: { gt: 1000000 } }) # OR Account(where: { OR: [{ Industry: { eq: "Technology" } }, { Industry: { eq: "Finance" } }] }) # NOT Account(where: { NOT: { Industry: { eq: "Technology" } } }) # Date literal / relative date Opportunity(where: { CloseDate: { eq: { value: "2024-12-31" } } }) Opportunity(where: { CloseDate: { gte: { literal: TODAY } } }) # Relationship filter (nested object, NOT dot notation) Contact(where: { Account: { Name: { like: "Acme%" } } }) # Polymorphic relationship filter Account(where: { Owner: { User: { Username: { like: "admin%" } } } }) ``` String `eq` is case-insensitive. Both 15- and 18-char record IDs are accepted. **Compound fields**: filter/order on constituents (`BillingCity`, `BillingCountry`), never the compound wrapper (`BillingAddress`) — the wrapper is selection-only. ## Ordering ```graphql Account(first: 10, orderBy: { Name: { order: ASC }, CreatedDate: { order: DESC } }) ``` Add `Id` as a tie-breaker for deterministic order. Unsupported for ordering: multi-select picklist, rich text, long text area, encrypted fields. ## Pagination - **Always include `first:`** — the server silently defaults to 10 if omitted. - Include `pageInfo { hasNextPage endCursor }` for anything paginatable. - Forward-only (`first` / `after`); `last` / `before` are unsupported. - `upperBound` (v59+) for large sets; when set, `first` must be 200–2000. ```graphql Account(first: 2000, after: $cursor, upperBound: 10000) { edges { node { Id Name @optional { value } } } pageInfo { hasNextPage endCursor } } ``` The shipped `accounts.ts` query parameterizes `$first` / `$after` and selects `pageInfo { hasNextPage endCursor }`; `Accounts.tsx` pages via "Load more" using `pageInfo.endCursor`. ## SOQL-derived limits Max 10 subqueries/request, 5 levels child→parent, 1 level parent→child (no grandchildren), 2,000 records/subquery. Split into multiple requests if exceeded. ## Field value wrappers Schema fields use typed wrappers; access via `.value`. Use `displayValue` (a `String`, server-rendered) for UI display instead of formatting client-side. | Wrapper | Underlying | Wrapper | Underlying | |---|---|---|---| | `StringValue` | String | `BooleanValue` | Boolean | | `IntValue` | Int | `DoubleValue` | Double | | `CurrencyValue` | Currency | `PercentValue` | Percent | | `DateTimeValue` | DateTime | `DateValue` | Date | | `PicklistValue` | Picklist | `LongValue` | Long | | `IDValue` | ID | `TextAreaValue` | TextArea | | `EmailValue` | Email | `PhoneNumberValue` | PhoneNumber | | `UrlValue` | Url | | | ## Semi-join / anti-join Filter a parent by conditions on children via `inq` (semi-join) / `ninq` (anti-join) on the parent's `Id`. If the only condition is child existence, use `Id: { ne: null }`. ```graphql Account(where: { Id: { inq: { Contact: { LastName: { like: "Smith%" } } ApiName: "AccountId" } } }, first: 10) { edges { node { Id Name @optional { value } } } } ``` Restrictions: no `OR` in subquery, no `orderBy` in subquery, no nested joins. ## Current user ```graphql query CurrentUser { uiapi { currentUser { Id Name { value } } } } ``` Do **not** use Chatter (`/chatter/users/me`). ## Mutations Mutations GA in v66+. Call via `sdk.graphql!.mutate({ mutation, variables })` (the document goes under the `mutation` key — see [sdk-api.md](sdk-api.md)). Wrap under `uiapi(input: { allOrNone: true | false })` and set `allOrNone` explicitly. ```graphql # Create mutation CreateAccount($input: AccountCreateInput!) { uiapi(input: { allOrNone: true }) { AccountCreate(input: $input) { Record { Id Name { value } } } } } # Update — must include Id mutation UpdateAccount($input: AccountUpdateInput!) { uiapi(input: { allOrNone: true }) { AccountUpdate(input: $input) { Record { Id Name { value } } } } } # Delete — generic RecordDeleteInput (NO per-entity delete type); Id is flat, not # nested under an entity key. Payload is RecordDeletePayload with `Id` ONLY — there # is no `Record` field to select back (selecting `Record` is a schema error). # Declare the variable `$input: RecordDeleteInput!` (its `Id` is `IdOrRef!`, NOT `ID!`, # so `$id: ID!` is rejected) — same `(input: $input)` call shape as Create/Update above. mutation DeleteAccount($input: RecordDeleteInput!) { uiapi(input: { allOrNone: true }) { AccountDelete(input: $input) { Id } } } # runtime: variables: { input: { Id: "001…" } } // flat Id — matches the spine's delete shape ``` Real consumer call (`userProfileApi.ts`): ```typescript const result = await sdk.graphql!.mutate({ mutation: UPDATE_USER_PROFILE, variables: { input: { Id: userId, User: { ...values } } }, }); if (result.errors?.length) throw new Error("An unexpected error occurred"); return result.data?.uiapi?.UserUpdate?.Record; ``` > Evidence note: the snippet above is reproduced from the shipped `userProfileApi.ts` to show the > *call mechanics* (the `mutate()` options bag and `result` handling) only — it predates the > `allOrNone` guardrail and omits the `uiapi(input: { allOrNone })` wrapper. Always author new > mutations with the wrapper set explicitly, as in the templates above; the guardrail wins over > this older shipped example. **Input constraints** - Create: required fields (unless `defaultedOnCreate`), only `createable` fields, no child relationships; reference fields set by `ApiName` (e.g. `AccountId`). - Update: must include `Id`, only `updateable` fields, no child relationships. - Delete: `Id` only. - `IdOrRef` (Update/Delete `Id`, and Create reference fields) accepts a literal record ID or a chaining reference `"@{Alias}"`. - Raw values only — no commas, currency symbols, or locale formatting (`80000`, not `"$80,000"`). **Output constraints** - Create/Update: output is always named `Record`; exclude child relationships and navigated reference fields (only the `ApiName` member is allowed). - Delete: `Id` only — the payload is `RecordDeletePayload`, which has **no `Record` field**; selecting `Record` (the Create/Update output) on a delete is a schema error. **`allOrNone` semantics** - `true` — all operations succeed or all roll back. - `false` — independent operations succeed individually; dependent operations (chained via `@{alias}`) still roll back together. ### Mutation chaining Chain related mutations with `@{alias}` references to an earlier mutation's `Id`. Required for parent-child creation (nested child creates are unsupported). ```graphql mutation CreateAccountAndContact { uiapi(input: { allOrNone: true }) { AccountCreate(input: { Account: { Name: "Acme" } }) { Record { Id } } ContactCreate(input: { Contact: { LastName: "Smith", AccountId: "@{AccountCreate}" } }) { Record { Id } } } } ``` Rules: `A` must appear before `B`; `@{A}` is always `A`'s `Id`; only `Create` or `Delete` can be chained *from* (not `Update`). ## Object metadata & picklist values Use `uiapi { objectInfos(...) }`. Pass **either** `apiNames` **or** `objectInfoInputs` — never both. ```graphql query GetObjectInfo($apiNames: [String!]!) { uiapi { objectInfos(apiNames: $apiNames) { ApiName label labelPlural fields { ApiName label dataType updateable createable } } } } query GetPicklistValues($objectInfoInputs: [ObjectInfoInput!]!) { uiapi { objectInfos(objectInfoInputs: $objectInfoInputs) { ApiName fields { ApiName ... on PicklistField { picklistValuesByRecordTypeIDs { recordTypeID picklistValues { label value } } } } } } } ``` ## Aggregations `uiapi { aggregate { … } }` mirrors `query` (one entry per record type) but returns aggregated buckets instead of rows — use it for counts/sums/grouped rollups so you don't pull every record client-side. Pass `groupBy:` (each grouped field gets `{ group: true }`) and select aggregate functions under `node { aggregate { … } }`. Scalar aggregates expose `count`/`countDistinct`/`min`/`max`; numerics (`IntAggregate`, etc.) add `avg`/`sum`. ```graphql # Count + average employees, grouped by Industry query AccountsByIndustry { uiapi { aggregate { Account(groupBy: { Industry: { group: true } }, first: 50) { edges { node { aggregate { Industry @optional { value } # aggregate functions are FieldValue wrappers — select { value } # (count/sum are LongValue, avg is DoubleValue), not bare. NumberOfEmployees @optional { count { value } avg { value } sum { value } } } } } totalCount } } } } ``` `first:` is still required, `@optional` still applies, results page like a normal connection. ## Related-list metadata `uiapi { relatedListByName(parentApiName:, relatedListName:) }` returns the *shape* of a parent object's related list — its display columns and ordering, not the child records themselves (query those via the child relationship). Both args are required. ```graphql query AccountContactsRelatedList { uiapi { relatedListByName(parentApiName: "Account", relatedListName: "Contacts") { label childApiName displayColumns { fieldApiName label sortable } orderedByInfo { fieldApiName sortDirection } } } } ``` ## Error patterns | Error contains | Resolution | |---|---| | `Cannot query field` / `ValidationError` / `validation error` | The *operation* is wrong or the type isn't accessible — re-ground the field (graphiti `sf-gql-discover`, or `graphql-search.sh ` on this fallback path) and fix the operation to the exact name from the Type definition; if it genuinely isn't in the org, deploy the metadata + assign perms then `npm run graphql:schema`. **Never edit `schema.graphql` to satisfy the validator** — it grants no org access and only hides the failure until runtime. | | `Unknown type` | Type name wrong — verify PascalCase entity name via script | | `Unknown argument` | Check Filter / OrderBy sections in script output | | `invalid syntax` / `InvalidSyntax` | Fix syntax per message | | `VariableTypeMismatch` / `UnknownType` | Correct argument type from schema | | `invalid cross reference id` | Entity deleted — ask for a valid Id | | `OperationNotSupported` | Check object availability and API version | | `is not currently available in mutation results` | Remove the field from mutation output | | `Cannot invoke JsonElement.isJsonObject()` | Use API v66+ for update-mutation `Record` selection | **On PARTIAL** (mutation returns both data and errors): report inaccessible fields, explain they cannot appear in mutation output, offer to remove them, and **wait for user consent** before changing.