import { CallExpressionShape, PropertyAccessShape, IdentifierShape, isGUID, type Record, type RecordId, type Factory, type ObjectShape, type Diagnostics, } from '@servicenow/sdk-build-core' import { getVariableTypeFromName, VariableTypeName, VARIABLE_TYPE_TO_NAME, isValidDependentQuestionParentType, } from './variable-helper' import { convertRolesToString, getVisibilityId, validateFieldNameBelongsToTable } from './utils' import { validateDependentQuestionType, validateMapToFieldRequiresField, validateMandatoryReadOnlyHidden, validateSelectionRequiredReadOnlyHidden, validateLookupSourceExclusivity, validateReferenceQualifierExclusivity, invalidDependentQuestionParentTypeMessage, } from './service-catalog-diagnostics' /** * Resolves dependentQuestion to the referenced variable's name string. Only the string-name form * (e.g. 'requestedFor') and the dot-walk form (e.g. employeeSet.variables.requestedFor) are supported; * a bare object reference (e.g. dependentQuestion: approverRef) is already rejected upstream by * validateDependentQuestionType before this is called. Any other unresolvable expression form falls * through to the trailing diagnostic below. */ function resolveDependentQuestion(config: ObjectShape, variableName: string, diagnostics: Diagnostics): string { // Use the raw (unresolved) shape so we can detect PropertyAccessShape before resolution flattens it. const dqRawShape = config.get('dependentQuestion', false) if (dqRawShape.isUndefined()) { return '' } if (dqRawShape.isString()) { return dqRawShape.ifString()?.getValue() ?? '' } // Dot-walk form: employeeContextVariableSet.variables.requestedFor // Extract the last element name — the rest of the path identifies the variable set (validated separately). const dqPropertyAccess = dqRawShape.if(PropertyAccessShape) if (dqPropertyAccess) { return dqPropertyAccess.getLastElement().getName() } diagnostics.error( dqRawShape, `'dependentQuestion' for '${variableName}' must be a string variable name (e.g. 'requestedFor') or a ` + `dot-walk into a variable set's variables (e.g. employeeSet.variables.requestedFor).` ) return '' } export async function buildVariableRecords(options: { factory: Factory diagnostics: Diagnostics variablesConfig: ObjectShape parent: Record parentArg?: ObjectShape }): Promise { const { variablesConfig, factory, parent, diagnostics, parentArg } = options const records: Record[] = [] let catItemRecord: Record | undefined let variableSetRecord: Record | undefined if (parent?.getTable() === 'sc_cat_item' || parent?.getTable() === 'sc_cat_item_producer') { catItemRecord = parent } if (parent?.getTable() === 'item_option_new_set') { variableSetRecord = parent } if (parent?.getTable() === 'sc_cat_item_producer' && parentArg) { validateFieldNameBelongsToTable(parentArg, diagnostics, 'RecordProducer') } const entries = Array.from(variablesConfig.entries()) // Used to reject dot-walk into a locally declared variable, and to let dependentQuestion // reference a variable regardless of declaration order (see nameToCalleeMap below). const localVariableNames = new Set() for (const [name] of entries) { localVariableNames.add(name) } // Reject a variable declared as a standalone top-level const and referenced by identifier // (e.g. `const approverRef = ReferenceVariable({...}); variables: { approver: approverRef }`). // Variables must be declared inline inside `variables: {...}` so their name/position/parent // container are always derivable from the AST — a bare identifier reference breaks that. for (const [name] of entries) { const rawValue = variablesConfig.get(name, false) if (rawValue.is(IdentifierShape)) { diagnostics.error( rawValue, `Variable '${name}' must be declared inline inside 'variables: {...}'. A direct reference to a ` + `variable const (e.g. ${name}: someVariableConst) is not supported.` ) } } // Maps variable key → created Record so dynamic_value_field gets the sys_id reference, not a raw string. const nameToVarRecord = new Map() // Maps variable key → its callee name, built upfront (not incrementally) so dependentQuestion can // reference a variable declared later in the same variables object — order doesn't matter for a // string-name reference, only for resolving the sys_id, which factory.createReference (keyed // lookup) handles regardless of creation order. const nameToCalleeMap = new Map() for (const [name, value] of entries) { const entryCallExpr = value.as(CallExpressionShape) nameToCalleeMap.set(name, entryCallExpr.getCallee()) } // Use for-of loop with await instead of forEach for (const [k, v] of entries) { const callExpr = v.as(CallExpressionShape) const calleeName = callExpr.getCallee() const variable = callExpr.getArgument(0) if (!variable.isObject() || !variable.asObject().isDefined()) { continue } const config = variable.asObject() // Validate mapToField/field consistency (applies to all non-minimal variables) if ( calleeName !== VariableTypeName.BREAK && calleeName !== VariableTypeName.CONTAINER_END && calleeName !== VariableTypeName.CONTAINER_SPLIT ) { validateMapToFieldRequiresField(config, diagnostics) } // Validate mandatory/readOnly/hidden consistency (applies to interactive variables) if (calleeName === VariableTypeName.CHECKBOX) { validateSelectionRequiredReadOnlyHidden(config, diagnostics) } else if ( calleeName !== VariableTypeName.BREAK && calleeName !== VariableTypeName.CONTAINER_END && calleeName !== VariableTypeName.CONTAINER_SPLIT && calleeName !== VariableTypeName.CONTAINER_START && calleeName !== VariableTypeName.LABEL && calleeName !== VariableTypeName.RICH_TEXT_LABEL && calleeName !== VariableTypeName.CUSTOM && calleeName !== VariableTypeName.CUSTOM_WITH_LABEL && calleeName !== VariableTypeName.UI_PAGE ) { validateMandatoryReadOnlyHidden(config, diagnostics) } // Validate lookup source exclusivity (applies to lookup variables) if ( calleeName === VariableTypeName.LOOKUP_SELECT_BOX || calleeName === VariableTypeName.LOOKUP_MULTIPLE_CHOICE ) { validateLookupSourceExclusivity(config, diagnostics) } // Validate reference qualifier exclusivity (applies to reference and requested-for variables) if (calleeName === VariableTypeName.REFERENCE) { validateReferenceQualifierExclusivity(config, diagnostics, 'ReferenceVariable') } if (calleeName === VariableTypeName.REQUESTED_FOR) { validateReferenceQualifierExclusivity(config, diagnostics, 'RequestedForVariable') } // Resolves dependentQuestion to a dynamic_value_field reference, in priority order: // 1. Type check the raw value: reject an object-ref (bare const) with a diagnostic; string // and dot-walk forms pass through. // 2. Resolve to a variable name (string as-is, or dot-walk's last path segment). // 3. If that name is a variable declared EARLIER in this same variables object, its Record // already exists in nameToVarRecord — use it directly, then check its type is valid. // 4. If that name is a variable declared LATER in this same variables object (a forward // reference), it isn't in nameToVarRecord yet — resolve it via a keyed reference // (cat_item/variable_set/name) instead of requiring the Record to already exist. // 5. Otherwise, search each attached VariableSet for a variable with that name and resolve it // the same way (a keyed reference — the VariableSet may not be built into a Record yet). // 6. If none of the above matched and the value isn't a raw sys_id, emit a "does not exist" // diagnostic. // 7. Fall back to writing a raw sys_id string only for legacy/opaque values that never // resolved to a known variable (e.g. from an out-of-scope reference). const dqTypeValid = validateDependentQuestionType(config, k, diagnostics) const dependentQuestionValue = dqTypeValid ? resolveDependentQuestion(config, k, diagnostics) : '' let dependentQuestionRecord = dependentQuestionValue ? nameToVarRecord.get(dependentQuestionValue) : undefined const dqRawForDiag = config.get('dependentQuestion', false) const dqPropertyAccess = dqRawForDiag.if(PropertyAccessShape) // Step 3 (continued): validate the backward-resolved variable's type. // Object-ref form was already rejected by resolveDependentQuestion above. const isDqStringOrDotWalk = dqRawForDiag.isString() || !!dqPropertyAccess if (dependentQuestionRecord && isDqStringOrDotWalk) { const referencedCallee = nameToCalleeMap.get(dependentQuestionValue) if (!isValidDependentQuestionParentType(referencedCallee)) { diagnostics.error(dqRawForDiag, invalidDependentQuestionParentTypeMessage(dependentQuestionValue)) dependentQuestionRecord = undefined } } // crossSetReference covers both step 5 (forward reference, keyed into this container) and // step 6 (VariableSet reference, keyed into an external set) below — both resolve by coalesce // keys rather than requiring the target's Record to already exist. let crossSetReference: RecordId | undefined let crossSetTypeError = false const isCatalogItemParent = parent?.getTable() === 'sc_cat_item' || parent?.getTable() === 'sc_cat_item_producer' const isLocalVariable = localVariableNames.has(dependentQuestionValue) // Step 4: forward reference (see pseudocode above). if (!dependentQuestionRecord && dependentQuestionValue && isLocalVariable) { const localCallee = nameToCalleeMap.get(dependentQuestionValue) if (!isValidDependentQuestionParentType(localCallee)) { diagnostics.error(dqRawForDiag, invalidDependentQuestionParentTypeMessage(dependentQuestionValue)) crossSetTypeError = true } else { crossSetReference = await factory.createReference({ source: dqRawForDiag, table: 'item_option_new', keys: { cat_item: catItemRecord ?? 'NULL', variable_set: variableSetRecord ?? 'NULL', name: dependentQuestionValue, }, }) } } // Step 5: search attached VariableSets. if ( !dependentQuestionRecord && dependentQuestionValue && !isGUID(dependentQuestionValue) && parentArg && isCatalogItemParent && !isLocalVariable ) { const variableSetsShape = parentArg.get('variableSets').ifArray() if (variableSetsShape) { for (const vsEntry of variableSetsShape.getElements()) { const vsObj = vsEntry.asObject() const vsShape = vsObj.get('variableSet') const vsResolved = vsShape.isResolvable() ? vsShape.resolve(true) : vsShape const vsCallExpr = vsResolved.if(CallExpressionShape) if (vsCallExpr && vsCallExpr.getCallee() === 'VariableSet') { // Inline definition — skip if this set doesn't contain the variable. const vsVariables = vsCallExpr .getArgument(0) .asObject() .get('variables') .ifDefined() ?.asObject() if (!vsVariables?.has(dependentQuestionValue)) { continue } // Validate that the cross-set variable is of a supported type. const vsVarShape = vsVariables.get(dependentQuestionValue, false) const vsVarCallExpr = vsVarShape.if(CallExpressionShape) if (vsVarCallExpr) { const vsVarCallee = vsVarCallExpr.getCallee() if (!isValidDependentQuestionParentType(vsVarCallee)) { diagnostics.error( dqRawForDiag, invalidDependentQuestionParentTypeMessage(dependentQuestionValue) ) crossSetTypeError = true break } } } else if (vsShape.isRecord()) { // VariableSet already committed as a Record (has $id). // Validate existence and type via Record children before creating the reference. const vsRecord = vsShape.asRecord() const varRecords = vsRecord.flat().filter((r: Record) => r.getTable() === 'item_option_new') const matchingVarRecord = varRecords.find( (r: Record) => r.get('name')?.getValue() === dependentQuestionValue ) if (!matchingVarRecord) { continue // Variable not in this set — try next } const varTypeCode = matchingVarRecord.get('type')?.getValue() as string | undefined const varTypeName = varTypeCode ? VARIABLE_TYPE_TO_NAME[varTypeCode] : undefined if (varTypeName && !isValidDependentQuestionParentType(varTypeName)) { diagnostics.error( dqRawForDiag, invalidDependentQuestionParentTypeMessage(dependentQuestionValue) ) crossSetTypeError = true break } } // Inline (confirmed), Record-validated, or truly opaque — create the reference. crossSetReference = await factory.createReference({ source: dqRawForDiag, table: 'item_option_new', keys: { cat_item: 'NULL', variable_set: vsShape, name: dependentQuestionValue }, }) break } } } // Step 6: isLocalVariable is fully handled above (either crossSetReference or crossSetTypeError // is set), so reaching here with a non-empty, non-GUID dependentQuestionValue means it matched // neither a local variable nor any attached variableSet. if ( dependentQuestionValue && !dependentQuestionRecord && !crossSetReference && !crossSetTypeError && !isGUID(dependentQuestionValue) ) { diagnostics.error( dqRawForDiag, isCatalogItemParent ? `'dependentQuestion' references '${dependentQuestionValue}' which does not exist in this catalog item's variables ` + `or any attached variableSets. If it belongs to a variable set, add that variable set to the variableSets array.` : `'dependentQuestion' references '${dependentQuestionValue}' which does not exist in this variable set's variables.` ) } // Step 7: fall back to raw string only for legacy sys_id values from incremental sync. const dependentQuestionFieldValue: Record | RecordId | string | undefined = dependentQuestionRecord ?? crossSetReference ?? (isGUID(dependentQuestionValue) ? dependentQuestionValue : undefined) const useEncryption = calleeName === VariableTypeName.MASKED ? (config.get('useEncryption')?.getValue() ?? true) : false const varType = getVariableTypeFromName(calleeName) const referenceTable = calleeName === VariableTypeName.REQUESTED_FOR ? 'sys_user' : config.get('referenceTable')?.getValue() const useReferenceQualifier = calleeName === VariableTypeName.LIST_COLLECTOR || calleeName === VariableTypeName.LOOKUP_SELECT_BOX || calleeName === VariableTypeName.LOOKUP_MULTIPLE_CHOICE ? 'advanced' : 'simple' const props = callExpr .getArgument(0) .asObject() .transform(({ $ }) => ({ name: $.val(k), type: $.val(varType).def(6), question_text: $.from('question').def(''), order: $.def(0), active: $.from('active').toBoolean().def(true), mandatory: calleeName === VariableTypeName.CHECKBOX ? $.from('selectionRequired').toBoolean().def(false) : $.from('mandatory').toBoolean().def(false), read_only: $.from('readOnly').toBoolean().def(false), hidden: $.from('hidden').toBoolean().def(false), disable_initial_slot_fill: $.from('disableInitialSlotFill').toBoolean().def(false), conversational_label: $.from('conversationalLabel').def(''), layout: $.from('layout').def('normal'), tooltip: $.from('tooltip').def(''), example_text: $.from('exampleText').def(''), show_help: $.from('showHelp').toBoolean().def(false), help_tag: $.from('helpTag').def('More information'), help_text: $.from('helpText').def(''), instructions: $.from('instructions').def('').toCdata(), variable_width: $.from('width').def(''), attributes: $.from('attributes').def(''), default_value: $.from('defaultValue').def(''), read_roles: $.from('readRoles').map(convertRolesToString).def([]), write_roles: $.from('writeRoles').map(convertRolesToString).def([]), create_roles: $.from('createRoles').map(convertRolesToString).def([]), delete_roles: $.from('deleteRoles').map(convertRolesToString).def([]), cat_item: $.val(catItemRecord).def(''), variable_set: $.val(variableSetRecord).def(''), reference: $.val(referenceTable).def(''), visible_standalone: $.from('visibleStandalone').toBoolean().def(true), visible_summary: $.from('visibleSummary').toBoolean().def(true), visible_guide: $.from('visibleGuide').toBoolean().def(true), visible_bundle: $.from('visibleBundle').toBoolean().def(true), not_available_conversation: $.from('removeFromConversationalInterfaces').toBoolean().def(false), display_title: $.from('displayTitle').toBoolean().def(false), macro: $.from('macro').def(''), summary_macro: $.from('summaryMacro').def(''), sp_widget: $.from('widget').def(''), macroponent: $.from('macroponent').def(''), topic_block: $.from('topicBlock').def(''), list_table: $.from('listTable').def(''), reference_qual: $.from('referenceQual').def(''), lookup_source: $.from('lookupSource').def(''), lookup_table: $.from('lookupFromTable').def(''), lookup_value: $.from('lookupValueField').def(''), lookup_label: $.from('lookupLabelFields') .def([]) .map((fields) => (Array.isArray(fields) ? fields.join(',') : fields)) .def(''), lookup_price: $.from('lookupPriceField').def(''), rec_lookup_price: $.from('lookupRecurringPriceField').def(''), choice_table: $.from('choiceTable').def(''), choice_field: $.from('choiceField').def(''), lookup_dependent_question: $.from('choicesDependOn').def(''), choice_direction: $.from('choiceDirection').def('down'), include_none: $.from('includeNone').toBoolean().def(false), lookup_unique: $.from('uniqueValuesOnly').toBoolean().def(false), mask_use_confirmation: $.from('useConfirmation').toBoolean().def(false), mask_use_encryption: $.val(useEncryption).toBoolean().def(false), do_not_select_first: $.from('doNotSelectFirstChoice').toBoolean().def(false), scale_min: $.from('scaleMin').def(0), scale_max: $.from('scaleMax').def(5), use_reference_qualifier: $.from('useReferenceQualifier').def(useReferenceQualifier), reference_qual_condition: $.from('referenceQualCondition').def(''), dynamic_ref_qual: $.from('dynamicRefQual').def(''), enable_also_request_for: $.from('enableAlsoRequestFor').toBoolean().def(false), rich_text: $.from('richText').toCdata().def(''), validate_regex: $.from('validateRegex').def(''), ui_page: $.from('uiPage').def(''), default_html_value: $.from('defaultHTML').toCdata().def(''), map_to_field: $.from('mapToField').toBoolean().def(false), field: $.from('field').def(''), delivery_plan: $.from('deliveryPlan').def(''), visibility: $.from('visibility') .map((v) => (v.isString() ? getVisibilityId(v.getValue()) : undefined)) .def(1), roles_to_use_also_request_for: $.from('rolesToUseAlsoRequestFor').map(convertRolesToString).def([]), category: $.from('category').def(''), pricing_implications: $.from('pricingImplications').toBoolean().def(false), show_help_on_load: $.from('alwaysExpand').toBoolean().def(false), use_dynamic_default: $.from('useDynamicDefault').toBoolean().def(false), // save_script: $.from('saveScript').toCdata().def(DEFAULT_SAVE_SCRIPT), read_script: $.from('readScript').toCdata().def(''), post_insert_script: $.from('postInsertScript').toCdata().def(''), unique: $.def(false), global: $.toBoolean().def(false), description: $.toCdata().def(''), dynamic_value_dot_walk_path: $.from('dotWalkPath').def(''), dynamic_value_field: $.val(dependentQuestionFieldValue).def(''), })) const varRecord = await factory.createRecord({ source: callExpr, table: 'item_option_new', properties: props, }) nameToVarRecord.set(k, varRecord) const varPricingDetails = config.get('pricingDetails').ifArray()?.getElements() ?? [] const varPricingDetailsRecords: Record[] = [] for (const pricingDetail of varPricingDetails) { const pricingDetailObj = pricingDetail.asObject() varPricingDetailsRecords.push( await factory.createRecord({ source: callExpr, table: 'fx_price', properties: { id: varRecord.getId().getValue(), field: pricingDetailObj.get('field').getValue(), amount: pricingDetailObj.get('amount').getValue(), currency: pricingDetailObj.get('currencyType').getValue(), type: 'calculated', table: 'item_option_new', }, }) ) } if ( (calleeName === VariableTypeName.MULTIPLE_CHOICE || calleeName === VariableTypeName.SELECT_BOX) && config.get('choices').isDefined() ) { const choices = config.get('choices').asObject() const entries = Array.from(choices.entries()) const choiceRecords: Record[] = [] // Use a regular for loop instead of forEach to handle async operations for (const [key, value] of entries) { const choiceObj = value.asObject() const choiceProps = choiceObj.transform(({ $ }) => ({ value: $.val(key), text: $.from('label').def(key), inactive: $.from('inactive').toBoolean().def(false), order: $.from('sequence').def(0), question: $.val(varRecord).def(''), })) const choiceRecord = await factory.createRecord({ source: callExpr, table: 'question_choice', properties: choiceProps, }) const pricingDetails = choiceObj.get('pricingDetails').ifArray()?.getElements() ?? [] const pricingDetailsRecords: Record[] = [] for (const pricingDetail of pricingDetails) { const pricingDetailObj = pricingDetail.asObject() pricingDetailsRecords.push( await factory.createRecord({ source: callExpr, table: 'fx_price', properties: { id: choiceRecord.getId().getValue(), amount: pricingDetailObj.get('amount')?.getValue(), currency: pricingDetailObj.get('currencyType')?.getValue(), type: 'calculated', field: pricingDetailObj.get('field')?.getValue(), table: 'question_choice', }, }) ) } choiceRecords.push(choiceRecord.with(...pricingDetailsRecords)) } // Now that all choiceRecords are created, add them to the varRecord records.push(varRecord.with(...choiceRecords)) } else { records.push(varRecord) } records.push(...varPricingDetailsRecords) } return records }