mirror of
https://github.com/forcedotcom/afv-library.git
synced 2026-08-01 04:16:17 +08:00
377 lines
16 KiB
Markdown
377 lines
16 KiB
Markdown
# 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 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<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.
|