afv-library/skills/experience-ui-bundle-salesforce-data-access/references/graphql-hand-authoring.md

377 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 <skill-dir>/scripts/graphql-search.sh <Entity>` before writing a query
(`<skill-dir>` = 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 <path>` (or set
`GRAPHQL_SCHEMA`). It prints the resolved schema path on stderr; confirm it's the right one.
```bash
bash <skill-dir>/scripts/graphql-search.sh Account
bash <skill-dir>/scripts/graphql-search.sh Account Contact Opportunity # multiple
bash <skill-dir>/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 `<Entity>CreateInput`, (5) Create fields `<Entity>CreateRepresentation`,
(6) Update wrapper `<Entity>UpdateInput`, (7) Update fields `<Entity>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 2002000.
```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<UpdateResult>({
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 <Entity>` 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.