/** * Copyright (c) 2026, Salesforce, Inc., * All rights reserved. * For full license text, see the LICENSE.txt file */ import type { GraphQLSchema, GraphQLType, GraphQLOutputType } from "graphql"; import { isEnumType, isScalarType, isInputObjectType, isListType, isNonNullType, getNamedType, } from "graphql"; import { getCachedObjectInfo, getRequiredCreateFields } from "./object-info.js"; import type { OperationType, QuerySession, FieldProjectionNode, FragmentProjectionNode, VariableDefinition, } from "./session.js"; import { getChildren } from "./session.js"; import { resolvePath, getRawFieldType, getFieldDescription, type WalkerResult } from "./walker.js"; /** * Collects all SObject names referenced in a session's projection nodes. * Includes primary SObjects (from query/mutation paths) and parent/child * relationship SObjects detected via schema type resolution. * Used to pre-warm the ObjectInfo cache before generating types. */ export function collectSessionSObjects(session: QuerySession, schema: GraphQLSchema): Set { const sObjects = new Set(); for (const node of session.nodes) { if (node.kind !== "field") continue; const fieldNode = node as FieldProjectionNode; const name = detectSObjectFromPath(fieldNode.schemaPath); if (name) sObjects.add(name); // Also resolve the node's schema path to detect relationship SObjects try { const wr = resolvePath(schema, session.operation, fieldNode.schemaPath); // If the resolved type looks like an SObject name (PascalCase, no Connection/Edge suffix) if ( wr.typeName && !wr.typeName.endsWith("Connection") && !wr.typeName.endsWith("Edge") && !wr.typeName.endsWith("Value") && !wr.isLeaf && /^[A-Z]/.test(wr.typeName) ) { sObjects.add(wr.typeName); } } catch { /* ignore resolution failures */ } } return sObjects; } const OUTPUT_SCALAR_MAP: Record = { String: "string", ID: "string", Int: "number", Float: "number", Boolean: "boolean", Picklist: "string", MultiPicklist: "string", Currency: "number", Date: "string", DateTime: "string", Time: "string", Double: "number", Percent: "number", Email: "string", PhoneNumber: "string", Url: "string", TextArea: "string", LongTextArea: "string", RichTextArea: "string", EncryptedString: "string", Base64: "string", IdOrRef: "string", JSON: "unknown", Long: "number", BigInteger: "number", BigDecimal: "number", Short: "number", Byte: "number", Char: "number", Latitude: "number", Longitude: "number", }; const INPUT_SCALAR_MAP: Record = { ...OUTPUT_SCALAR_MAP, Currency: "number | string", BigDecimal: "number | string", Double: "number | string", Percent: "number | string", Longitude: "number | string", Latitude: "number | string", }; interface PicklistTypeInfo { typeName: string; values: string[]; fieldLabel: string; objectName: string; } /** * Supported target languages for `codegen`. * Currently only `typescript` is implemented; add new entries here when * introducing additional emitters. */ export const SUPPORTED_CODEGEN_LANGUAGES = ["typescript"] as const; export type CodegenLanguage = (typeof SUPPORTED_CODEGEN_LANGUAGES)[number]; /** * Accepts either the canonical language name (`typescript`) or a common * alias (e.g. `ts`), normalising to a `CodegenLanguage`. Returns `null` * when the input does not match a supported language. */ export function normalizeCodegenLanguage(value: string): CodegenLanguage | null { const v = value.trim().toLowerCase(); if (v === "typescript" || v === "ts") return "typescript"; return null; } export interface CodegenOptions { typeName?: string; language?: CodegenLanguage; } export function generateTypes( session: QuerySession, schema: GraphQLSchema, options: CodegenOptions = {}, ): string { const language = options.language ?? "typescript"; if (language !== "typescript") { throw new Error( `Unsupported codegen language: "${language}". Supported languages: ${SUPPORTED_CODEGEN_LANGUAGES.join(", ")}.`, ); } const typeName = options.typeName; const rootChildren = getChildren(session, null); if (rootChildren.length === 0) { return `// No fields selected in this session.\n`; } const picklists: PicklistTypeInfo[] = []; const resultType = buildNodeType(session, schema, null, 1, session.operation, [], picklists); const name = typeName ?? session.name ?? session.id; const pascalName = toPascalCase(name); const parts: string[] = []; // Collect input type declarations first (may add picklist types) const inputTypeDecls = new Map(); if (session.variables.length > 0) { for (const v of session.variables) { collectInputTypeDeclarations(schema, v.type, inputTypeDecls, 0, session, picklists); } } // Picklist type aliases (emitted after input collection so mutation input picklists are included) for (const pl of picklists) { parts.push(`/** ${pl.objectName}.${pl.fieldLabel} picklist values */`); const union = pl.values.map((v) => `"${v.replace(/"/g, '\\"')}"`).join(" | "); parts.push(`type ${pl.typeName} = ${union};`); parts.push(""); } // Input type declarations if (session.variables.length > 0) { for (const [, code] of inputTypeDecls) { if (code) { parts.push(code); parts.push(""); } } } // Variables interface if (session.variables.length > 0) { parts.push(`export interface ${pascalName}Variables {`); for (const v of session.variables) { const tsType = variableToTsType(v, session, schema, picklists, inputTypeDecls); parts.push(` ${v.name}${v.type.endsWith("!") ? "" : "?"}: ${tsType};`); } parts.push("}"); parts.push(""); } // Result interface parts.push(`export interface ${pascalName}Result {`); parts.push(resultType); parts.push("}"); return parts.join("\n") + "\n"; } function buildNodeType( session: QuerySession, schema: GraphQLSchema, parentId: string | null, depth: number, operation: string, currentSchemaPath: string[], picklists: PicklistTypeInfo[], ): string { const _indent = " ".repeat(depth); const children = getChildren(session, parentId); const lines: string[] = []; for (const child of children) { if (child.kind === "field") { const fieldLine = buildFieldType( session, schema, child, depth, operation, currentSchemaPath, picklists, ); lines.push(fieldLine); } else if (child.kind === "fragment") { const fragLine = buildFragmentType( session, schema, child, depth, operation, currentSchemaPath, picklists, ); lines.push(fragLine); } } return lines.join("\n"); } function buildFieldType( session: QuerySession, schema: GraphQLSchema, node: FieldProjectionNode, depth: number, operation: string, parentSchemaPath: string[], picklists: PicklistTypeInfo[], ): string { const indent = " ".repeat(depth); const fieldSchemaPath = [...parentSchemaPath, node.fieldName]; const displayName = node.alias ?? node.fieldName; const subChildren = getChildren(session, node.id); const hasOptional = node.directives.some((d) => d.name === "optional"); const optionalMarker = hasOptional ? "?" : ""; const undefinedSuffix = hasOptional ? " | undefined" : ""; // Resolve the type of this field let wr: WalkerResult | null = null; try { wr = resolvePath(schema, operation as OperationType, fieldSchemaPath); } catch { return `${indent}${displayName}${optionalMarker}: unknown${undefinedSuffix};`; } // Get the raw field type from the schema to determine nullability and list-ness const rawType = getRawFieldType( schema, operation as OperationType, parentSchemaPath, node.fieldName, ); const { isList: schemaIsList } = unwrapTypeInfo(rawType); let { nullable: schemaNullable } = unwrapTypeInfo(rawType); // UIAPI connection edges never contain null nodes — the API returns fewer edges // rather than edges with null nodes. Override the schema nullability for `node` // fields inside `edges` arrays to avoid unnecessary null checks in generated types. if ( node.fieldName === "node" && parentSchemaPath.length > 0 && parentSchemaPath[parentSchemaPath.length - 1] === "edges" ) { schemaNullable = false; } // UIAPI always returns an `edges` array (possibly empty), never null. // Override the schema nullability for `edges` fields inside connection types. if (node.fieldName === "edges" && schemaIsList) { schemaNullable = false; } const fieldDesc = getFieldDescription( schema, operation as OperationType, parentSchemaPath, node.fieldName, ); const jsdocPrefix = fieldDesc ? `${indent}/** ${fieldDesc} */\n` : ""; if (wr.isLeaf) { let tsType: string; if (isEnumType(wr.type)) { tsType = wr.type .getValues() .map((v) => `"${v.name}"`) .join(" | "); } else { tsType = scalarToTs(wr.typeName); } const nullSuffix = schemaNullable ? " | null" : ""; return `${jsdocPrefix}${indent}${displayName}${optionalMarker}: ${tsType}${nullSuffix}${undefinedSuffix};`; } if (subChildren.length === 0) { return `${jsdocPrefix}${indent}${displayName}${optionalMarker}: Record${undefinedSuffix};`; } const nested = buildNodeType( session, schema, node.id, depth + 1, operation, fieldSchemaPath, picklists, ); const enriched = tryEnrichPicklist( session, node, parentSchemaPath, wr, picklists, schema, operation, ); if (enriched) { let result = enriched.replaceAll("__INDENT__", indent).replaceAll("__NAME__", displayName); if (hasOptional) { result = result.replace(`${displayName}:`, `${displayName}?:`); result = result.replace(/;$/, `${undefinedSuffix};`); } return `${jsdocPrefix}${result}`; } const nullSuffix = schemaNullable ? " | null" : ""; if (schemaIsList) { return `${jsdocPrefix}${indent}${displayName}${optionalMarker}: Array<{\n${nested}\n${indent}}>${nullSuffix}${undefinedSuffix};`; } // Check if children contain inline fragments — produce discriminated union const fragmentChildren = subChildren.filter( (c) => c.kind === "fragment", ) as FragmentProjectionNode[]; if (fragmentChildren.length > 0) { const branches: string[] = []; for (const frag of fragmentChildren) { const fragLine = buildFragmentType( session, schema, frag, depth, operation, fieldSchemaPath, picklists, ); if (fragLine) branches.push(fragLine); } const fieldChildren = subChildren.filter((c) => c.kind === "field"); const fieldLines: string[] = []; for (const fc of fieldChildren) { fieldLines.push( buildFieldType( session, schema, fc as FieldProjectionNode, depth + 1, operation, fieldSchemaPath, picklists, ), ); } const _fieldBlock = fieldLines.length > 0 ? fieldLines.join("\n") + "\n" : ""; if (branches.length === 1) { return `${jsdocPrefix}${indent}${displayName}${optionalMarker}: ${branches[0]}${nullSuffix}${undefinedSuffix};`; } const unionType = branches.join(" | "); return `${jsdocPrefix}${indent}${displayName}${optionalMarker}: ${unionType}${nullSuffix}${undefinedSuffix};`; } return `${jsdocPrefix}${indent}${displayName}${optionalMarker}: {\n${nested}\n${indent}}${nullSuffix}${undefinedSuffix};`; } function buildFragmentType( session: QuerySession, schema: GraphQLSchema, node: FragmentProjectionNode, depth: number, operation: string, parentSchemaPath: string[], picklists: PicklistTypeInfo[], ): string { const indent = " ".repeat(depth); const subChildren = getChildren(session, node.id); if (subChildren.length === 0) { return ""; } const fragmentSchemaPath = [...parentSchemaPath, `[${node.onType}]`]; const nested = buildNodeType( session, schema, node.id, depth + 1, operation, fragmentSchemaPath, picklists, ); // Return as an object block with __typename for discriminated unions (no leading indent — parent handles it) return `{\n${indent} __typename: "${node.onType}";\n${nested}\n${indent}}`; } function tryEnrichPicklist( session: QuerySession, node: FieldProjectionNode, parentSchemaPath: string[], wr: WalkerResult, picklists: PicklistTypeInfo[], schema: GraphQLSchema, operation: string, ): string | null { // Check if this is a PicklistValue or PicklistAggregate type if (!wr.typeName.includes("Picklist")) return null; // Try to find the SObject and field name for this picklist let sObjectName = detectSObjectFromPath(parentSchemaPath); let objInfo = sObjectName ? getCachedObjectInfo(session.orgAlias, sObjectName) : null; let plInfo = objInfo?.picklists.find((p) => p.apiName === node.fieldName); // Fallback: resolve the parent schema path to get the actual type name. // This handles parent relationships (e.g. Case→Account→Industry) and child // relationships (e.g. Order→OrderItems→node→Status) where the root SObject // detection returns the wrong SObject. if ((!plInfo || plInfo.values.length === 0) && parentSchemaPath.length > 0) { try { const parentWr = resolvePath(schema, operation as OperationType, parentSchemaPath); const parentType = parentWr.typeName; if (parentType && parentType !== sObjectName) { const altInfo = getCachedObjectInfo(session.orgAlias, parentType); if (altInfo) { const altPl = altInfo.picklists.find((p) => p.apiName === node.fieldName); if (altPl && altPl.values.length > 0) { sObjectName = parentType; objInfo = altInfo; plInfo = altPl; } } } } catch { /* ignore resolution failures */ } } if (!sObjectName || !objInfo) return null; if (!plInfo || plInfo.values.length === 0) return null; const values = plInfo.values.map((v) => v.value).filter((v): v is string => v !== null); if (values.length === 0) return null; // Suppress single-"None" picklist unions — they're placeholder values, not meaningful constraints if (values.length === 1 && values[0] === "None") return null; const picklistTypeName = `${sObjectName}${toPascalCase(node.fieldName)}`; if (!picklists.some((p) => p.typeName === picklistTypeName)) { picklists.push({ typeName: picklistTypeName, values, fieldLabel: node.fieldName, objectName: sObjectName, }); } // Build the sub-fields with the enriched picklist type const subChildren = getChildren(session, node.id); if (subChildren.length === 0) return null; // `PicklistValue` and `PicklistAggregate` both pass the typeName check // above, but their children have different shapes. Branch on the exact // type so each child renders correctly. // // PicklistAggregate children (min/max/count/countDistinct/value) wrap // the picklist value in their own object: e.g. `min: PicklistValue`, // not `min: Picklist`. Without this branch the loop would hit the // `unknown` fallback for every child and silently violate FR-10.2 + // FR-10.5 (e2e Gap 4: aggregate min/max emits `string | null` instead // of the picklist literal union). const isAggregate = wr.typeName === "PicklistAggregate"; const lines: string[] = []; for (const child of subChildren) { if (child.kind !== "field") continue; const fc = child as FieldProjectionNode; const name = fc.alias ?? fc.fieldName; if (isAggregate) { if (fc.fieldName === "min" || fc.fieldName === "max" || fc.fieldName === "value") { // `value` here is the mode-style aggregation (rare). All three // resolve to a PicklistValue wrapper around the picklist union. lines.push(`__INDENT__ ${name}: { value: ${picklistTypeName} | null } | null;`); } else if (fc.fieldName === "count" || fc.fieldName === "countDistinct") { lines.push(`__INDENT__ ${name}: { value: number | null } | null;`); } else { lines.push(`__INDENT__ ${name}: unknown;`); } } else { if (fc.fieldName === "value") { lines.push(`__INDENT__ ${name}: ${picklistTypeName} | null;`); } else if (fc.fieldName === "displayValue" || fc.fieldName === "label") { lines.push(`__INDENT__ ${name}: string | null;`); } else { lines.push(`__INDENT__ ${name}: unknown;`); } } } return `__INDENT____NAME__: {\n${lines.join("\n")}\n__INDENT__} | null;`; } function detectSObjectFromPath(schemaPath: string[]): string | null { const queryIdx = schemaPath.indexOf("query"); if (queryIdx !== -1 && schemaPath[queryIdx + 1]) { return schemaPath[queryIdx + 1]; } const aggIdx = schemaPath.indexOf("aggregate"); if (aggIdx !== -1 && schemaPath[aggIdx + 1]) { return schemaPath[aggIdx + 1]; } // Mutation: look for *Create/*Update patterns for (const seg of schemaPath) { const match = seg.match(/^(\w+?)(?:Create|Update|Delete)$/); if (match) return match[1]; } return null; } /** * Unwraps a GraphQLOutputType to determine nullability and list-ness. * Falls back to nullable:true when the raw type is unavailable (e.g. root * query fields or unresolvable paths) as a safe default. */ function unwrapTypeInfo(rawType: GraphQLOutputType | null): { nullable: boolean; isList: boolean } { if (!rawType) return { nullable: true, isList: false }; const nonNull = isNonNullType(rawType); const inner = nonNull ? rawType.ofType : rawType; return { nullable: !nonNull, isList: isListType(inner) || (isNonNullType(inner) && isListType(inner.ofType)), }; } function scalarToTs(typeName: string): string { return OUTPUT_SCALAR_MAP[typeName] ?? "unknown"; } /** * Large INPUT_OBJECT types (entity filters with many fields) are only expanded * at depth 0 (the variable's own type). Small types (operators, value inputs) * are always expanded regardless of depth. This prevents a combinatorial explosion * when filter types cross-reference other entity filters (e.g. Case_Filter → * Account_Filter → Contact_Filter → ...). */ const LARGE_TYPE_FIELD_THRESHOLD = 25; const LARGE_TYPE_MAX_DEPTH = 1; /** * Recursively collects TypeScript type declarations for all INPUT_OBJECT and ENUM * types transitively referenced by the given GraphQL type string. */ function collectInputTypeDeclarations( schema: GraphQLSchema, graphqlTypeString: string, declarations: Map, depth = 0, session?: QuerySession, picklists?: PicklistTypeInfo[], ): void { const baseTypeName = graphqlTypeString.replace(/[![\]]/g, ""); if (declarations.has(baseTypeName)) return; if (INPUT_SCALAR_MAP[baseTypeName]) return; const schemaType = schema.getType(baseTypeName); if (!schemaType) return; if (isScalarType(schemaType)) return; if (isEnumType(schemaType)) { const values = schemaType .getValues() .map((v) => `"${v.name}"`) .join(" | "); declarations.set(baseTypeName, `type ${baseTypeName} = ${values};`); return; } if (isInputObjectType(schemaType)) { const fields = Object.values(schemaType.getFields()); // Only depth-limit types that are BOTH large themselves AND reference other // large INPUT_OBJECT types. This targets the entity-filter cross-reference // pattern (Case_Filter → Account_Filter → Contact_Filter → ...) while // always expanding small operator types (IdOperators, DateTimeOperators) // even when they reference large utility types like JoinInput. const isLargeType = fields.length > LARGE_TYPE_FIELD_THRESHOLD; if (isLargeType && depth >= LARGE_TYPE_MAX_DEPTH) { const referencesOtherLargeInputTypes = fields.some((f) => { const ft = getNamedType(f.type); if (!ft || ft.name === baseTypeName) return false; if (!isInputObjectType(ft)) return false; return Object.keys(ft.getFields()).length > LARGE_TYPE_FIELD_THRESHOLD; }); if (referencesOtherLargeInputTypes) { // Emit a named type alias so references use the type name instead of // Record. The body is opaque but the name is preserved. declarations.set(baseTypeName, `type ${baseTypeName} = Record;`); return; } } // Register placeholder to break circular references (e.g. Case_Filter.and: [Case_Filter]) declarations.set(baseTypeName, ""); // Detect SObject name from representation types (e.g. CaseCreateRepresentation → Case) const reprMatch = baseTypeName.match(/^(\w+?)(?:Create|Update)Representation$/); const reprSObject = reprMatch ? reprMatch[1] : null; const objInfo = reprSObject && session ? getCachedObjectInfo(session.orgAlias, reprSObject) : null; // Detect SObject name from filter types (e.g. Case_Filter → Case) const filterMatch = baseTypeName.match(/^(\w+?)_Filter$/); const filterSObject = filterMatch ? filterMatch[1] : null; const filterObjInfo = filterSObject && session ? getCachedObjectInfo(session.orgAlias, filterSObject) : null; const lines: string[] = []; for (const field of fields) { const namedFieldType = getNamedType(field.type); if (namedFieldType) { collectInputTypeDeclarations( schema, namedFieldType.name, declarations, depth + 1, session, picklists, ); } // Enrich picklist fields in filter types with field-specific operator types let tsType = inputTypeToTs(field.type, declarations); if (filterObjInfo && picklists && tsType === "PicklistOperators") { const plInfo = filterObjInfo.picklists.find((p) => p.apiName === field.name); if (plInfo && plInfo.values.length > 0) { const values = plInfo.values.map((v) => v.value).filter((v): v is string => v !== null); if (values.length > 0 && !(values.length === 1 && values[0] === "None")) { const picklistTypeName = `${filterSObject}${toPascalCase(field.name)}`; if (!picklists.some((p) => p.typeName === picklistTypeName)) { picklists.push({ typeName: picklistTypeName, values, fieldLabel: field.name, objectName: filterSObject!, }); } // Generate a field-specific operator type with the picklist union const opsTypeName = `${picklistTypeName}Operators`; if (!declarations.has(opsTypeName)) { const opsLines = [ ` eq?: ${picklistTypeName};`, ` ne?: ${picklistTypeName};`, ` in?: ${picklistTypeName}[];`, ` nin?: ${picklistTypeName}[];`, ` like?: string;`, ` lt?: string;`, ` gt?: string;`, ` lte?: string;`, ` gte?: string;`, ]; declarations.set( opsTypeName, `interface ${opsTypeName} {\n${opsLines.join("\n")}\n}`, ); } tsType = opsTypeName; } } } // Enrich picklist fields in mutation representations with union types if (objInfo && picklists && tsType === "string") { const plInfo = objInfo.picklists.find((p) => p.apiName === field.name); if (plInfo && plInfo.values.length > 0) { const values = plInfo.values.map((v) => v.value).filter((v): v is string => v !== null); if (values.length > 0 && !(values.length === 1 && values[0] === "None")) { const picklistTypeName = `${reprSObject}${toPascalCase(field.name)}`; if (picklists && !picklists.some((p) => p.typeName === picklistTypeName)) { picklists.push({ typeName: picklistTypeName, values, fieldLabel: field.name, objectName: reprSObject!, }); } tsType = picklistTypeName; } } } let optional = !isNonNullType(field.type); // For Create representations, check ObjectInfo for required-on-create fields if (optional && objInfo && baseTypeName.endsWith("CreateRepresentation")) { const requiredFields = getRequiredCreateFields(objInfo); if (requiredFields.some((f) => f.apiName === field.name)) { optional = false; } } lines.push(` ${field.name}${optional ? "?" : ""}: ${tsType};`); } declarations.set(baseTypeName, `interface ${baseTypeName} {\n${lines.join("\n")}\n}`); } } /** * Maps a GraphQL input type (possibly wrapped in NonNull/List) to its TypeScript * representation string. NonNull wrappers are stripped because optionality is * handled at the field declaration site. Types not present in the declarations * map (skipped due to depth limits) fall back to Record. */ function inputTypeToTs(type: GraphQLType, declarations: Map): string { let t = type; if (isNonNullType(t)) t = t.ofType; if (isListType(t)) { return `${inputTypeToTs(t.ofType, declarations)}[]`; } const named = getNamedType(t); if (!named) return "unknown"; if (INPUT_SCALAR_MAP[named.name]) return INPUT_SCALAR_MAP[named.name]; if (declarations.has(named.name)) return named.name; if (isInputObjectType(named)) return "Record"; if (isEnumType(named)) return "string"; return "unknown"; } function variableToTsType( v: VariableDefinition, session: QuerySession, schema: GraphQLSchema, picklists: PicklistTypeInfo[], inputDecls: Map, ): string { const baseType = v.type.replace(/[![\]]/g, ""); const isList = v.type.includes("["); const isNN = v.type.endsWith("!"); let tsType: string; if (INPUT_SCALAR_MAP[baseType]) { tsType = INPUT_SCALAR_MAP[baseType]; } else if (inputDecls.has(baseType)) { tsType = baseType; } else { const schemaType = schema.getType(baseType); if (schemaType && isInputObjectType(schemaType)) { tsType = "Record"; } else { tsType = "unknown"; } } // Enrich Picklist variables with union types by tracing the variable's binding if (baseType === "Picklist" || baseType === "MultiPicklist") { const enriched = enrichPicklistVariable(session, v.name, picklists); if (enriched) tsType = enriched; } if (isList) tsType = `${tsType}[]`; if (!isNN) tsType += " | null"; return tsType; } /** * Traces a Picklist variable's binding through the session's node args to find * the SObject and field name, then returns the picklist union type alias. * For example, $status bound to Case/@args/where/Status/eq → CaseStatus. * * Args are stored as flat key-value pairs on nodes. The `where` arg is a JSON * string like `{"Status":{"eq":"$status"}}`. We search for `$varName` in the * JSON and extract the field name from the enclosing key path. */ function enrichPicklistVariable( session: QuerySession, varName: string, picklists: PicklistTypeInfo[], ): string | null { const varRef = `$${varName}`; for (const node of session.nodes) { if (node.kind !== "field") continue; const fieldNode = node as FieldProjectionNode; for (const [argKey, argVal] of Object.entries(fieldNode.args)) { if (!argVal.includes(varRef)) continue; // For direct arg bindings like "after": "$after" if (argVal === varRef && argKey !== "where") continue; // For JSON where clauses, parse and find the field name containing $varRef let fieldName: string | null = null; if (argKey === "where") { try { fieldName = findPicklistFieldInWhere(JSON.parse(argVal), varRef); } catch { continue; } } if (!fieldName) continue; const sObjectName = detectSObjectFromPath(fieldNode.schemaPath); if (!sObjectName) continue; const objInfo = getCachedObjectInfo(session.orgAlias, sObjectName); if (!objInfo) continue; const plInfo = objInfo.picklists.find((p) => p.apiName === fieldName); if (!plInfo || plInfo.values.length === 0) continue; const values = plInfo.values.map((v) => v.value).filter((v): v is string => v !== null); if (values.length === 0) continue; if (values.length === 1 && values[0] === "None") continue; const picklistTypeName = `${sObjectName}${toPascalCase(fieldName)}`; if (!picklists.some((p) => p.typeName === picklistTypeName)) { picklists.push({ typeName: picklistTypeName, values, fieldLabel: fieldName, objectName: sObjectName, }); } return picklistTypeName; } } return null; } /** * Recursively searches a parsed where clause object for a variable reference * and returns the field name it's bound to. * E.g. {"Status":{"eq":"$status"}} → "Status" */ function findPicklistFieldInWhere(obj: unknown, varRef: string): string | null { if (typeof obj !== "object" || obj === null) return null; for (const [key, val] of Object.entries(obj as Record)) { if (typeof val === "string" && val === varRef) { // We're at the operator level (eq/ne/in). Return null — parent will return the field name. return null; } if (typeof val === "object" && val !== null) { // Check if this value directly contains the varRef at operator level for (const [, opVal] of Object.entries(val as Record)) { if (opVal === varRef) return key; } // Recurse for nested and/or const found = findPicklistFieldInWhere(val, varRef); if (found) return found; } } return null; } function toPascalCase(str: string): string { return str .replace(/[^a-zA-Z0-9]/g, " ") .split(/\s+/) .map((w) => w.charAt(0).toUpperCase() + w.slice(1)) .join(""); }