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

16 KiB
Raw Blame History

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. 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). 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 <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

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:

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 relationshipOwner @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

# 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

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.
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 }.

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

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). Wrap under uiapi(input: { allOrNone: true | false }) and set allOrNone explicitly.

# 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):

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).

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.

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.

# 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.

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.

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.