import { type Compiler, ts, CallExpressionShape, type OutputFile, Plugin, unloadBuilder, isSNScope, type Record, type RecordId, type Shape, VariableStatementShape, IdentifierShape, type ObjectShape, } from '@servicenow/sdk-build-core' import { XMLParser } from 'fast-xml-parser' import { z } from 'zod' import { columnToCallExpression, documentationToLabelShape, generateLabel, generatePlural, labelShapeToDocumentation, } from './column-plugin' import { create } from 'xmlbuilder2' import type { XMLBuilder } from 'xmlbuilder2/lib/interfaces' import { addFieldsToColumn } from './column/column-helper' import { getLabelForDefaultLanguage } from './column/column-to-record' import { generateDeprecatedDiagnostics } from './utils' import { PLATFORM_COLUMNS } from '@servicenow/sdk-core/runtime/db' import type { TableActionAccess } from '@servicenow/sdk-core/db' import { isEqual } from 'lodash' type GlobalRecord = globalThis.Record // Bootstrap XML schema const BooleanFromString = z .string() .transform((str) => str === 'true') .or(z.boolean()) .optional() const ChoiceElementSchema = z.object({ '@_label': z.string().optional(), '@_value': z.string().optional(), '@_sequence': z.coerce.number().optional(), '@_dependent_value': z.coerce.number().or(z.string()).optional(), '@_hint': z.string().optional(), '@_inactive': BooleanFromString.optional(), '@_inactive_on_update': BooleanFromString.optional(), '@_language': z.string().optional(), }) const IndexElementSchema = z.object({ '@_name': z.string(), }) const IndexSchema = z.object({ '@_name': z.string().optional(), '@_unique': BooleanFromString.optional(), element: z.array(IndexElementSchema).or(IndexElementSchema), }) const ColumnSchema = z .object({ '@_name': z.string(), '@_type': z.string().optional(), '@_internal_type': z.string().optional(), '@_default': z.string().optional(), '@_default_value': z.string().optional(), '@_max_length': z.coerce.number().or(z.string()).optional(), '@_mandatory': BooleanFromString.optional(), '@_read_only': BooleanFromString.optional(), '@_read_only_option': z.string().optional(), '@_reference_table': z.string().optional(), '@_reference_qual': z.string().optional(), '@_label': z.string().optional(), '@_choice': z.coerce.number().optional(), '@_choice_type': z.coerce.number().optional(), '@_active': BooleanFromString.optional(), '@_display': BooleanFromString.optional(), '@_use_dependent_field': BooleanFromString.optional(), '@_use_dynamic_default': BooleanFromString.optional(), '@_reference': z.string().optional(), '@_virtual': BooleanFromString.optional(), '@_formula': z.string().optional(), '@_virtual_type': z.string().optional(), '@_use_reference_qualifier': z.string().optional(), '@_dynamic_ref_qual': z.string().optional(), '@_calculation': z.string().optional(), '@_choice_field': z.string().optional(), '@_function_definition': z.string().optional(), '@_reference_floats': BooleanFromString.optional(), '@_reference_key': z.string().optional(), '@_mtom': z.string().optional(), '@_dynamic_creation_script': z.string().optional(), '@_dynamic_creation': BooleanFromString.optional(), '@_plural': z.string().optional(), '@_hint': z.string().optional(), '@_help': z.string().optional(), '@_url': z.string().optional(), '@_url_target': z.string().optional(), '@_widget': z.string().optional(), '@_table_reference': BooleanFromString.optional(), '@_element_reference': BooleanFromString.optional(), '@_unique': BooleanFromString.optional(), '@_spell_check': BooleanFromString.optional(), '@_xml_view': BooleanFromString.optional(), '@_array': BooleanFromString.optional(), '@_text_index': BooleanFromString.optional(), '@_primary': BooleanFromString.optional(), '@_attributes': z.string().optional(), '@_audit': BooleanFromString.optional(), choice: z .object({ element: z.array(ChoiceElementSchema).or(ChoiceElementSchema).optional(), }) .optional(), }) .catchall(z.any()) const TableSchema = z .object({ '@_name': z.string(), '@_extends': z.string().optional(), '@_label': z.string().optional(), '@_plural': z.string().optional(), '@_hint': z.string().optional(), '@_help': z.string().optional(), '@_url': z.string().optional(), '@_url_target': z.string().optional(), '@_audit': BooleanFromString.optional(), '@_read_only': BooleanFromString.optional(), '@_text_index': BooleanFromString.optional(), // No defaults: an access attribute absent from the bootstrap XML is left undefined so it // is omitted from the generated metadata rather than being asserted on the developer's behalf. '@_client_scripts_access': BooleanFromString.optional(), '@_ws_access': BooleanFromString.optional(), '@_alter_access': BooleanFromString.optional(), '@_create_access': BooleanFromString.optional(), '@_delete_access': BooleanFromString.optional(), '@_update_access': BooleanFromString.optional(), '@_actions_access': BooleanFromString.optional(), '@_read_access': BooleanFromString.optional(), '@_is_extendable': BooleanFromString.optional().default(false), '@_scriptable_table': BooleanFromString.optional().default(false), '@_attributes': z.string().optional(), element: z.array(ColumnSchema).or(ColumnSchema).optional(), index: z.array(IndexSchema).or(IndexSchema).optional(), }) .catchall(z.any()) const BootstrapDatabaseSchema = z.object({ element: TableSchema, }) // Parsed table definitions type ChoiceDefinition = { label: string | undefined value: string sequence: number | undefined dependentValue: string | number | undefined hint: string | undefined inactive: boolean | undefined inactiveOnUpdate: boolean | undefined language: string | undefined } type ColumnDefinition = { name: string type: string | undefined defaultValue: string | undefined maxLength: number | string | undefined isMandatory: boolean | undefined isReadOnly: boolean | undefined readOnlyOption: string | undefined referenceTable: string | undefined referenceQual: string | undefined columnLabel: string | undefined choices: ChoiceDefinition[] | undefined isActive: boolean | undefined display: boolean | undefined useDependentField: boolean | undefined useDynamicDefault: boolean | undefined reference: string | undefined isVirtual: boolean | undefined formula: string | undefined virtualType: 'script' | 'formula' | undefined useReferenceQualifier: 'simple' | 'dynamic' | 'advanced' | undefined dynamicRefQual: string | undefined calculation: string | undefined choiceField: string | undefined functionDefinition: string | undefined referenceFloats: boolean | undefined referenceKey: string | undefined mtom: string | undefined dynamicCreationScript: string | undefined dynamicCreation: boolean | undefined plural: string | undefined hint: string | undefined help: string | undefined url: string | undefined urlTarget: string | undefined widget: string | undefined tableReference: boolean | undefined elementReference: boolean | undefined unique: boolean | undefined spellCheck: boolean | undefined xmlView: boolean | undefined isArray: boolean | undefined textIndex: boolean | undefined isPrimary: boolean | undefined attributes: string | undefined referenceCascadeRule: string | undefined choiceValue: number | undefined choiceTable: string | undefined dependent: string | undefined audit: boolean | undefined } type IndexDefinition = { name: string | undefined unique: boolean | undefined columns: string[] } type TableDefinition = { name: string label: string | undefined plural: string | undefined hint: string | undefined help: string | undefined url: string | undefined urlTarget: string | undefined extends: string | undefined columns: ColumnDefinition[] indexes: IndexDefinition[] audit: boolean | undefined readOnly: boolean | undefined textIndex: boolean | undefined clientScriptsAccess: boolean | undefined wsAccess: boolean | undefined alterAccess: boolean | undefined createAccess: boolean | undefined deleteAccess: boolean | undefined updateAccess: boolean | undefined callerAccess: number | undefined actionsAccess: boolean | undefined readAccess: boolean | undefined isExtendable: boolean | undefined createAccessControls: boolean | undefined userRole: string | undefined scriptableTable: boolean | undefined attributes: string | undefined display: string | undefined } // sys_db_object & related record properties type SysDbObjectProperties = { name: string label: string | undefined super_class: string | undefined is_extendable: boolean | undefined create_access_controls: boolean | undefined user_role: string | undefined scriptable_table: boolean | undefined client_scripts_access: boolean | undefined ws_access: boolean | undefined alter_access: boolean | undefined create_access: boolean | undefined delete_access: boolean | undefined update_access: boolean | undefined caller_access: number | undefined actions_access: boolean | undefined read_access: boolean | undefined } type SysDictionaryProperties = { name: string internal_type: string element: string | undefined default_value: string | undefined audit: boolean | undefined max_length: number | string | undefined mandatory: boolean | undefined read_only: boolean | undefined read_only_option: string | undefined reference_table: string | undefined reference_qual: string | undefined column_label: string | undefined active: boolean | undefined display: boolean | undefined use_dependent_field: boolean | undefined use_dynamic_default: boolean | undefined reference: string | undefined virtual: boolean | undefined formula: string | undefined virtual_type: 'script' | 'formula' | undefined use_reference_qualifier: 'simple' | 'dynamic' | 'advanced' | undefined dynamic_ref_qual: string | undefined default: string | undefined calculation: string | undefined choice_field: string | undefined function_definition: string | undefined reference_floats: boolean | undefined reference_key: string | undefined mtom: string | undefined dynamic_creation_script: string | undefined dynamic_creation: boolean | undefined plural: string | undefined hint: string | undefined help: string | undefined url: string | undefined url_target: string | undefined widget: string | undefined table_reference: boolean | undefined element_reference: boolean | undefined unique: boolean | undefined spell_check: boolean | undefined xml_view: boolean | undefined array: boolean | undefined text_index: boolean | undefined primary: boolean | undefined attributes: string | undefined reference_cascade_rule: string | undefined choice: number | undefined choice_table: string | undefined dependent: string | undefined function_field: boolean | undefined } type SysChoiceProperties = { name: string element: string label: string value: string sequence: number | undefined dependent_value: string | number | undefined hint: string | undefined inactive: boolean | undefined inactive_on_update: boolean | undefined language: string | undefined } type SysIndexProperties = { index_name?: string col_name_string: string unique_index: boolean | undefined logical_table_name: string } type SysDocumentationProperties = { name: string element: string | undefined label: string | undefined plural: string | undefined language: string hint: string | undefined help: string | undefined url: string | undefined url_target: string | undefined } type SysDictionaryOverrideProperties = { name: string element: string base_table: string | undefined default: string | undefined calculation: string | undefined reference_qual: string | undefined read_only_option: string | undefined dependent: string | undefined mandatory: boolean | undefined display: boolean | undefined attributes: string | undefined } /** * The access levels are mapped to the following values on the platform * * - `none`: 0 * - `tracking`: 1 * - `restricted`: 2 * */ const callerAccessLevels = ['none', 'tracking', 'restricted'] as const /** * Reads a `sys_db_object` access flag from a record. Values can arrive as booleans or as * 'true'/'false' strings depending on the source. Anything else - including the empty string an * empty XML element parses to - means the flag was never set, and stays undefined so the * corresponding property is omitted from the generated Fluent code instead of being asserted * as `false`. */ function toAccessFlag(shape: Shape): boolean | undefined { const value = shape.ifBoolean()?.getValue() if (value !== undefined) { return value } switch (shape.ifString()?.getValue().trim().toLowerCase()) { case 'true': return true case 'false': return false default: return undefined } } const tableNameRegex = /^[a-z_][a-z0-9_]*[a-z0-9]$/ const columnNameRegex = /^[a-z_][a-z0-9_]*$/ const tableAliases = { readOnly: ['read_only'], textIndex: ['text_index'], allowWebServiceAccess: ['allow_web_service_access'], allowNewFields: ['allow_new_fields'], allowUiActions: ['allow_ui_actions'], allowClientScripts: ['allow_client_scripts'], licensingConfig: ['licensing_config'], liveFeed: ['live_feed'], accessibleFrom: ['accessible_from'], callerAccess: ['caller_access'], scriptableTable: ['scriptable_table'], autoNumber: ['auto_number'], } const autoNumberAliases = { numberOfDigits: ['number_of_digits'], } const licensingAliases = { licenseModel: ['license_model'], ownerCondition: ['owner_condition'], licenseCondition: ['license_condition'], isFulfillment: ['is_fulfillment'], opDelete: ['op_delete'], opUpdate: ['op_update'], opInsert: ['op_insert'], licenseRoles: ['license_roles'], } export const TablePlugin = Plugin.create({ name: 'TablePlugin', files: [ { matcher: /\.xml$/, async toRecord(file, { factory, config }) { const xml = new XMLParser({ ignoreAttributes: false, alwaysCreateTextNode: true, htmlEntities: true, }).parse(file.content).database if (!xml) { return { success: false } } const tableDef = parseTableBootstrapXml(xml) if (!tableDef) { return { success: false } } const recordDefs = tableDefToRecordProperties(tableDef, config.defaultLanguage) const records: Record[] = [] for (const [key, table] of [ ['sysDbObject', 'sys_db_object'], ['sysDictionary', 'sys_dictionary'], ['sysChoice', 'sys_choice'], ['sysIndex', 'sys_index'], ['sysDocumentation', 'sys_documentation'], ['sysDictionaryOverride', 'sys_dictionary_override'], ] as const) { for (const rec of [recordDefs[key]].flat()) { records.push( await factory.createRecord({ source: file, table, properties: { ...filterUndefinedProperties(rec), // Decorate generated sys_db_object ...(key === 'sysDbObject' || key === 'sysDictionary' ? { _bootstrap: true } : {}), }, }) ) } } const [sysDbRecord, ...relatedRecords] = records return { success: true, value: sysDbRecord!.with(...relatedRecords), } }, }, ], records: { sys_db_object: { coalesce: ['name'], relationships: { sys_dictionary: { via: { name: 'name' }, descendant: true, relationships: { sys_documentation: { descendant: true, via: { name: 'name', element: 'element', }, }, sys_choice: { descendant: true, via: { name: 'name', element: 'element', }, }, sys_choice_set: { descendant: true, via: { name: 'name', element: 'element', }, }, }, }, sys_documentation: { descendant: true, via: { name: 'name' }, }, sys_dictionary_override: { via: { name: 'name' }, descendant: true, }, ua_table_licensing_config: { descendant: true, via: { name: 'name' }, }, sys_number: { descendant: true, via: { category: 'name' }, }, sys_index: { descendant: true, via: { logical_table_name: 'name' }, }, sys_db_object: { descendant: false, via: 'super_class', }, }, toShape(record, { descendants, config, compiler }) { const schema: { [key: string]: CallExpressionShape } = {} let displayColumn: string | undefined const columns = descendants.query('sys_dictionary') const overrides = descendants.query('sys_dictionary_override') let collectionRecord: Record const choices = descendants.query('sys_choice') const documentation = descendants.query('sys_documentation') // Process regular columns for (const column of columns) { if (column.get('internal_type').getValue() === 'collection') { // 'collection' sys_dictionary record only has table properties collectionRecord = column continue } const columnName = column.get('element').asString().getValue() schema[columnName] = columnToCallExpression(column, { choices: choices.filter((choice) => choice.get('element').asString().getValue() === columnName), documentation: documentation.filter( (d) => d.get('element').ifString()?.getValue() === columnName ), defaultLanguage: config.defaultLanguage, }) if (column.get('display').ifDefined()?.toBoolean().getValue()) { displayColumn = columnName } } // Process dictionary overrides as OverrideColumn for (const override of overrides) { const columnName = override.get('element').asString().getValue() schema[columnName] = new CallExpressionShape({ source: override, callee: 'OverrideColumn', args: [ override.transform(({ $ }) => ({ baseTable: $.from('base_table'), default: $.from('default_value_override', 'default_value').map((flag, value) => { return flag.ifDefined() && flag.toBoolean()?.getValue() ? value.ifString()?.getValue() : undefined }), calculation: $.from('calculation_override', 'calculation').map((flag, value) => { return flag.ifDefined() && flag.toBoolean()?.getValue() ? value.ifString()?.getValue() : undefined }), referenceQualifier: $.from('reference_qual_override', 'reference_qual').map( (flag, value) => { return flag.ifDefined() && flag.toBoolean()?.getValue() ? value.ifString()?.getValue() : undefined } ), readOnlyOption: $.from('read_only_option_override', 'read_only_option').map( (flag, value) => { return flag.ifDefined() && flag.toBoolean()?.getValue() ? value.ifString()?.getValue() : undefined } ), dependent: $.from('dependent_override', 'dependent').map((flag, value) => { return flag.ifDefined() && flag.toBoolean()?.getValue() ? value.ifString()?.getValue() : undefined }), mandatory: $.from('mandatory_override', 'mandatory').map((flag, value) => { return flag.ifDefined() && flag.toBoolean()?.getValue() ? value.toBoolean()?.getValue() : undefined }), attributes: $.from('attributes_override', 'attributes').map((flag, attrs) => { if (!flag.ifDefined() || !flag.toBoolean()?.getValue() || !attrs.isString()) { return undefined } const result: { [key: string]: string | number | boolean } = {} attrs .toString() .getValue() .split(',') .forEach((attr) => { if (attr === '') { return } const [key, value] = attr.split('=').map((s) => s.trim()) if (!key || value === undefined) { return } if (value === 'true') { result[key] = true } else if (value === 'false') { result[key] = false } else { // Try to parse as number const numValue = Number(value) if (!isNaN(numValue) && value !== '') { result[key] = numValue } else { result[key] = value } } }) return result }), display: $.from('display_override').map((v) => { if (!v.ifDefined()) { return undefined } const boolValue = v.toBoolean().getValue() return boolValue === true ? true : undefined }), })), ], }) } const tableDocumentation = documentation.filter((d) => !d.get('element').getValue()) const columnNames = new Set(Object.keys(schema)) const unhandledDocs = documentation.filter((d) => { const element = d.get('element').ifString()?.getValue() return element && !columnNames.has(element) }) const licensing = descendants.query('ua_table_licensing_config') const autoNumber = descendants.query('sys_number') const indexes = descendants.query('sys_index') const originalSource = record.getOriginalSource() // Avoid replacing call expressions with variable statements const writeAsCallExpression = ts.Node.isNode(originalSource) && originalSource.isKind(ts.SyntaxKind.CallExpression) const tableName = record.get('name').asString().getValue() const isBootstrapDbObject = record.get('_bootstrap').ifBoolean()?.getValue() === true // exclude 'collection' sys_dictionary record - it defines table properties // and results in tables being incorrectly identified as augments const nonBootstrapColumns = columns.filter( (col) => col.get('_bootstrap').ifBoolean()?.getValue() !== true && !col.equals(collectionRecord) ) // Write as augmentation if we have sys_db_object from bootstrap and columns from elsewhere const isAugmentation = isBootstrapDbObject && nonBootstrapColumns.length > 0 if (isAugmentation) { const augmentsExpression = new CallExpressionShape({ source: record, callee: 'Table', exportName: tableName, args: [ record.transform(({ $ }) => ({ augments: $.val(tableName), schema: $.val(schema), })), ], }) registerBootstrappedTable(augmentsExpression, schema, compiler) const augValue = writeAsCallExpression ? augmentsExpression : new VariableStatementShape({ source: record, isExported: true, variableName: new IdentifierShape({ source: record, name: tableName, }), initializer: augmentsExpression, }) if (unhandledDocs.length > 0) { return { success: 'partial' as const, value: augValue, unhandledRecords: unhandledDocs } } return { success: true, value: augValue } } const callExpression = new CallExpressionShape({ source: record, callee: 'Table', exportName: record.get('name').ifString()?.getValue(), args: [ record .transform(({ $ }) => ({ accessibleFrom: $.from('access') .map((access) => (access.ifString()?.getValue() === '' ? undefined : access)) .def('public'), actions: $.from('read_access', 'update_access', 'delete_access', 'create_access').map( (readAccess, updateAccess, deleteAccess, createAccess) => { const actions: TableActionAccess = {} for (const [action, shape] of [ ['read', readAccess], ['update', updateAccess], ['delete', deleteAccess], ['create', createAccess], ] as const) { const value = toAccessFlag(shape) if (value !== undefined) { actions[action] = value } } return Object.keys(actions).length ? actions : undefined } ), allowClientScripts: $.from('client_scripts_access').map(toAccessFlag), allowNewFields: $.from('alter_access').map(toAccessFlag), allowUiActions: $.from('actions_access').map(toAccessFlag), allowWebServiceAccess: $.from('ws_access').map(toAccessFlag), attributes: $.val( (() => { const attributes = collectionRecord?.get('attributes') if (!attributes?.isString()) { return undefined } const result: { [key: string]: string | boolean } = {} attributes .toString() .getValue() .split(',') .forEach((attr) => { if (attr === '') { return } const [key, value] = attr.split('=').map((s) => s.trim()) if (!key) { return } // value === undefined handles a case where attribute is present // with no value. Treat presence of key as 'true' if (value === 'true' || value === undefined) { result[key] = true } else if (value === 'false') { result[key] = false } else { result[key] = value } }) return result })() ).def({}), audit: $.val(collectionRecord?.get('audit')).toBoolean().def(false), autoNumber: $.val( autoNumber[0] ?.transform(({ $ }) => ({ number: $.toNumber().def(1000), numberOfDigits: $.from('maximum_digits') .map((v) => v.ifString()?.ifNotEmpty()?.toNumber()) .def(7), prefix: $.def('PRE'), })) .withAliasedKeys(autoNumberAliases) ), callerAccess: $.from('caller_access') .map((callerAccess) => { // An empty element arrives as '', not 0 — guard before // '' and 0 both mean "none" on the platform — omit from generated Fluent. if (callerAccess.isString() && callerAccess.getValue() === '') { return '' } if (callerAccess.isNumber()) { const value = callerAccess.getValue() return value === 0 ? '' : callerAccessLevels[value] } if (callerAccess.isString()) { const parsed = Number(callerAccess.getValue()) if (!isNaN(parsed)) { return parsed === 0 ? '' : callerAccessLevels[parsed] } } return '' }) .def(''), display: displayColumn ? $.val(displayColumn) : undefined, extends: $.from('super_class').def(''), extensible: $.from('is_extendable').toBoolean().def(false), index: $.val( indexes.map((idx) => { return idx.transform(({ $ }) => ({ //index_name is used to retain the index name from bootstrap XML if one exists name: $.from('index_name'), unique: $.from('unique_index').map( (uniqueIndex) => uniqueIndex.ifBoolean() ?? false ), element: $.from('col_name_string').map((colNamesString) => { const colNames = colNamesString.asString().getValue().split(',') return colNames.length > 1 ? colNames : colNames[0] }), })) }) ).def([]), label: documentationToLabelShape( $, tableDocumentation, record.get('label').ifString()?.getValue(), generateLabel(tableName), config.defaultLanguage ), licensingConfig: licensing.length ? $.val( licensing[0]! .transform(({ $ }) => ({ licenseModel: $.from('license_model').def('none'), ownerCondition: $.from('owner_condition').def(''), licenseCondition: $.from('license_condition').def(''), isFulfillment: $.from('is_fulfillment').toBoolean().def(false), opDelete: $.from('op_delete').toBoolean().def(true), opUpdate: $.from('op_update').toBoolean().def(true), opInsert: $.from('op_insert').toBoolean().def(true), licenseRoles: $.from('license_roles') .map((v) => { return v.isString() && !v.isEmpty() ? v .asString() .getValue() .split(',') .map((role) => role.trim()) : [] }) .def([]), })) .withAliasedKeys(licensingAliases) ).def({ licenseModel: 'none', ownerCondition: '', licenseCondition: '', isFulfillment: false, opDelete: true, opUpdate: true, opInsert: true, licenseRoles: [], }) : undefined, liveFeed: $.from('live_feed_enabled').toBoolean().def(false), name: $, readOnly: $.val(collectionRecord?.get('read_only')).toBoolean().def(false), schema: $.val(schema), // create_access_controls and user_role only ever arrive here from the // sys_db_object record. The platform's bootstrap XML pipeline does not // touch either field (no references in /glide/db/bootstrap/xml/ or // TableDescriptorProvider.createTableLevelMetaData), and the SDK's own // bootstrap output is always paired with sys_db_object_.xml that // carries the record values. So the bootstrap-only string path through // tableDefToRecordProperties is unreachable for these two fields. createAccessControls: $.from('create_access_controls').toBoolean().def(false), userRole: $.from('user_role') .map((v) => v.ifRecordId()?.getPrimaryKey()) .def(''), scriptableTable: $.from('scriptable_table').toBoolean().def(false), textIndex: $.val(collectionRecord?.get('text_index')).toBoolean().def(false), })) .withAliasedKeys(tableAliases), ], }) registerBootstrappedTable(callExpression, schema, compiler) const value = writeAsCallExpression ? callExpression : new VariableStatementShape({ source: record, isExported: true, variableName: new IdentifierShape({ source: record, name: record.get('name').asString().getValue(), }), initializer: callExpression, }) if (unhandledDocs.length > 0) { return { success: 'partial' as const, value, unhandledRecords: unhandledDocs } } return { success: true, value } }, async toFile(record, { descendants, config }) { if (record.isDeleted()) { return { success: false } } const augmentsValue = record.get('augments').ifString()?.getValue() const isAugmentation = augmentsValue !== undefined const tableName = augmentsValue ?? record.get('name').asString().getValue() const columns = descendants.query('sys_dictionary') const choices = descendants.query('sys_choice') const indexes = descendants.query('sys_index') const documentation = descendants.query('sys_documentation') const licensing = descendants.query('ua_table_licensing_config') const autoNumber = descendants.query('sys_number') const overrides = descendants.query('sys_dictionary_override') const skipDictionaryAndDbObject = !config.emitDictionary && config.type !== 'configuration' // Group column-level documentation (records with a defined `element`) by column so we // can tell, per column, whether its label array has more than one entry. const columnDocumentation = new Map() const tableDocumentation: Record[] = [] for (const doc of documentation) { const element = doc.get('element').ifString()?.getValue() if (!element) { tableDocumentation.push(doc) continue } const columnGroup = columnDocumentation.get(element) if (columnGroup) { columnGroup.push(doc) } else { columnDocumentation.set(element, [doc]) } } // A column's non-default documentation always has its first entry inlined into the // bootstrap XML (regardless of emitDictionary) so hint/help/plural/url/urlTarget // survive even when no standalone sys_documentation record is written for it. const inlineColumnDocumentation = new Map() for (const [element, group] of columnDocumentation) { inlineColumnDocumentation.set(element, group[0]!) } const documentationToWrite = skipDictionaryAndDbObject ? documentation.filter((doc) => { const source = doc.getSource() const creator = source && source instanceof CallExpressionShape ? source.getCallee() : undefined if (creator === 'Record') { // Always output documentation defined as a separate Record entity return true } // Check if we have multiple to write const element = doc.get('element').ifString()?.getValue() if (!element) { return tableDocumentation.length > 1 } const group = columnDocumentation.get(element)! return group.length > 1 }) : documentation const [ documentationFiles, licensingFiles, autoNumberFiles, overrideFiles, sysDictionaryFiles, sysDbObjectFiles, ] = await Promise.all([ generateRecordXml(documentationToWrite), generateRecordXml(licensing.filter((l) => !isDefaultLicenseConfig(tableName, l))), generateRecordXml(autoNumber), generateRecordXml(overrides), skipDictionaryAndDbObject ? Promise.resolve([]) : generateRecordXml(columns, ['_bootstrap', 'mtom']), skipDictionaryAndDbObject || isAugmentation ? Promise.resolve([]) : generateRecordXml([record], ['augments', '_bootstrap']), ]) if (config.type === 'configuration') { // No bootstrap XML for configuration projects, just write independent record XML // index_name is a carrier field used only by toFile for bootstrap XML — exclude from component XML const indexFiles = await generateRecordXml(indexes, ['index_name']) return { success: true, value: [ ...sysDictionaryFiles, ...indexFiles, ...sysDbObjectFiles, ...documentationFiles, ...licensingFiles, ...autoNumberFiles, ...overrideFiles, ], } } let displayColumn: string | undefined let collectionRecord: Record | undefined const elements: XMLBuilder[] = [] for (const column of columns) { const displayValue = column.get('display').ifBoolean()?.getValue() if (displayValue) { displayColumn = column.get('element').asString().getValue() } if (column.get('internal_type').asString().getValue() === 'collection') { // collection element has only table properties collectionRecord = column continue } const defaultValue = column.get('default_value') const maxLength = column.get('max_length') const dependentOnField = column.get('dependent_on_field').ifString() const inlineDoc = inlineColumnDocumentation.get(column.get('element').asString().getValue()) const columnAttributes: [string, string | undefined][] = [ ['name', column.get('element').asString().getValue()], ['type', column.get('internal_type').asString().getValue()], ['active', column.get('active').ifBoolean()?.getValue().toString()], ['array', column.get('array').ifBoolean()?.getValue().toString()], ['audit', column.get('audit').ifBoolean()?.getValue().toString()], [ 'default_value', defaultValue.ifString()?.getValue() ?? defaultValue.ifNumber()?.toString().getValue() ?? defaultValue.ifBoolean()?.toString().getValue() ?? defaultValue .ifArray() ?.getElements() .map((v) => v.asString().getValue()) .join(', '), ], ['unique', column.get('unique').ifBoolean()?.getValue().toString()], ['label', column.get('column_label').ifString()?.getValue()], ['max_length', maxLength.ifNumber()?.getValue().toString() ?? maxLength.ifString()?.getValue()], ['mandatory', column.get('mandatory').ifBoolean()?.getValue().toString()], ['read_only', column.get('read_only').ifBoolean()?.getValue().toString()], ['read_only_option', column.get('read_only_option').ifString()?.getValue()], ['reference_cascade_rule', column.get('reference_cascade_rule').ifString()?.getValue()], ['calculation', column.get('calculation').ifString()?.getValue()], // Bootstrap convention: emit `choice="0"` for the platform default ("none"). // The component (install) path uses an empty `` element instead, but // bootstrap files conventionally include the literal "0" attribute. ['choice', (column.get('choice').ifNumber()?.getValue() ?? 0).toString()], ['choice_table', column.get('choice_table').ifString()?.getValue()], ['choice_field', column.get('choice_field').ifString()?.getValue()], ['display', displayValue?.toString()], ['function_field', column.get('function_field').ifBoolean()?.getValue().toString()], ['attributes', column.get('attributes').ifString()?.getValue()], ['inactive', column.get('inactive').ifBoolean()?.getValue().toString()], ['dynamic_default', column.get('dynamic_default').ifString()?.getValue()], ['spell_check', column.get('spell_check').ifBoolean()?.getValue().toString()], ['xml_view', column.get('xml_view').ifBoolean()?.getValue().toString()], ['table_reference', column.get('table_reference').ifBoolean()?.getValue().toString()], ['text_index', column?.get('text_index').ifBoolean()?.getValue().toString()], ['element_reference', column.get('element_reference').ifBoolean()?.getValue().toString()], ['primary', column.get('primary').ifBoolean()?.getValue().toString()], ['dependent', column.get('dependent').ifString()?.getValue().toString()], ['dependent_on_field', dependentOnField?.getValue()], [ 'use_dependent_field', dependentOnField ? (!!dependentOnField.getValue()).toString() : undefined, ], ['use_dynamic_default', column.get('use_dynamic_default').ifBoolean()?.getValue().toString()], ['reference', column.get('reference').ifString()?.getValue()], ['function_definition', column.get('function_definition').ifString()?.getValue()], ['reference_floats', column.get('reference_floats').ifBoolean()?.getValue().toString()], ['reference_key', column.get('reference_key').ifString()?.getValue()], ['mtom', isSNScope(config.scope) ? column.get('mtom').ifString()?.getValue() : undefined], ['dynamic_creation_script', column.get('dynamic_creation_script').ifString()?.getValue()], ['dynamic_creation', column.get('dynamic_creation').ifBoolean()?.getValue().toString()], [ 'plural', inlineDoc?.get('plural').ifString()?.getValue() || column.get('plural').ifString()?.getValue() || undefined, ], [ 'hint', inlineDoc?.get('hint').ifString()?.getValue() || column.get('hint').ifString()?.getValue() || undefined, ], [ 'help', inlineDoc?.get('help').ifString()?.getValue() || column.get('help').ifString()?.getValue() || undefined, ], [ 'url', inlineDoc?.get('url').ifString()?.getValue() || column.get('url').ifString()?.getValue() || undefined, ], [ 'url_target', inlineDoc?.get('url_target').ifString()?.getValue() || column.get('url_target').ifString()?.getValue() || undefined, ], ['virtual', column.get('virtual').ifBoolean()?.getValue().toString()], ['formula', column.get('formula').ifString()?.getValue() || undefined], [ 'virtual_type', column.get('virtual_type').ifString()?.getValue() === 'script' ? undefined : column.get('virtual_type').ifString()?.getValue(), ], [ 'use_reference_qualifier', column.get('use_reference_qualifier').ifString()?.getValue() === 'simple' ? undefined : column.get('use_reference_qualifier').ifString()?.getValue(), ], [ 'dynamic_ref_qual', column.get('dynamic_ref_qual').ifDefined()?.toString().getValue() || undefined, ], ['widget', column.get('widget').ifString()?.getValue()], ['reference_qual', column.get('reference_qual').ifString()?.getValue()], ['reference_qual_condition', column.get('reference_qual_condition').ifString()?.getValue()], ] const choiceElements = choices .filter( (choice) => choice.get('element').asString().getValue() === column.get('element').asString().getValue() ) .map((choice) => createElement( 'element', filterUndefinedAttributes([ ['value', choice.get('value').ifString()?.getValue()], ['label', choice.get('label').ifString()?.getValue()], [ 'dependent_value', choice.get('dependent_value').ifNumber()?.getValue().toString() ?? choice.get('dependent_value').ifString()?.asString().getValue(), ], ['inactive', choice.get('inactive').ifBoolean()?.getValue().toString()], [ 'inactive_on_update', choice.get('inactive_on_update').ifBoolean()?.getValue().toString(), ], ['sequence', choice.get('sequence').ifNumber()?.getValue().toString()], ['hint', choice.get('hint').ifString()?.getValue()], ['synonyms', choice.get('synonyms').ifString()?.getValue()], ['language', choice.get('language').ifString()?.getValue()], ]) ) ) elements.push( createElement( 'element', filterUndefinedAttributes(columnAttributes), choiceElements.length ? [createElement('choice', [], choiceElements)] : [] ) ) } const indexElements = indexes.map((index) => { const indexAttributes: [string, string | undefined][] = [ ['name', index.get('index_name').ifString()?.getValue()], ['unique', index.get('unique_index').ifBoolean()?.getValue().toString()], ] const indexColumns = index .get('col_name_string') .asString() .getValue() .split(',') .map((col) => createElement('element', [['name', col.trim()]])) return createElement('index', filterUndefinedAttributes(indexAttributes), indexColumns) }) // Same treatment for the table's own (element-less) documentation: a single // non-default entry is inlined into the bootstrap XML's root element so // hint/help/plural/url/urlTarget survive even when no standalone sys_documentation // record is written for it. Multiple entries (e.g. multiple languages) can't be // inlined into a single set of attributes, so they're left to documentationToWrite. const inlineTableDocumentation = tableDocumentation.length === 1 ? tableDocumentation[0] : undefined const tableLabel = record.get('label').ifString()?.getValue() const inlinePlural = tableLabel && generatePlural(tableLabel) !== inlineTableDocumentation?.get('plural').ifString()?.getValue() const tableAttributes: [string, string | undefined][] = [ ['name', tableName], ['type', 'collection'], ['label', tableLabel], ['extends', record.get('super_class').ifRecordId()?.getPrimaryKey()], ['is_extendable', record.get('is_extendable').ifBoolean()?.getValue().toString()], ['text_index', collectionRecord?.get('text_index').ifBoolean()?.getValue().toString()], ['read_only', collectionRecord?.get('read_only').ifBoolean()?.getValue().toString()], ['audit', collectionRecord?.get('audit').ifBoolean()?.getValue().toString()], ['display', displayColumn], ['access', record.get('access').ifString()?.getValue()], // Bootstrap convention: emit `caller_access="0"` for the platform default ("none"). // The component (install) path uses an empty `` element instead. ['caller_access', (record.get('caller_access').ifNumber()?.getValue() ?? 0).toString()], ['ws_access', record.get('ws_access').ifBoolean()?.getValue().toString()], ['read_access', record.get('read_access').ifBoolean()?.getValue().toString()], ['alter_access', record.get('alter_access').ifBoolean()?.getValue().toString()], ['create_access', record.get('create_access').ifBoolean()?.getValue().toString()], ['update_access', record.get('update_access').ifBoolean()?.getValue().toString()], ['delete_access', record.get('delete_access').ifBoolean()?.getValue().toString()], ['actions_access', record.get('actions_access').ifBoolean()?.getValue().toString()], ['client_scripts_access', record.get('client_scripts_access').ifBoolean()?.getValue().toString()], // create_access_controls and user_role are omitted: the platform's bootstrap // pipeline doesn't read them. They're carried by the paired sys_db_object_.xml. ['scriptable_table', record.get('scriptable_table').ifBoolean()?.getValue().toString()], ['attributes', collectionRecord?.get('attributes').ifString()?.getValue()], [ 'plural', inlinePlural ? inlineTableDocumentation?.get('plural').ifString()?.getValue() || undefined : undefined, ], ['hint', inlineTableDocumentation?.get('hint').ifString()?.getValue() || undefined], ['help', inlineTableDocumentation?.get('help').ifString()?.getValue() || undefined], ['url', inlineTableDocumentation?.get('url').ifString()?.getValue() || undefined], ['url_target', inlineTableDocumentation?.get('url_target').ifString()?.getValue() || undefined], ] const tableXml = createElement( 'database', [], [ createElement('element', filterUndefinedAttributes(tableAttributes), [ ...elements, ...indexElements, ]), ] ).end({ prettyPrint: true }) return { success: true, value: [ { source: record, name: `${tableName}.xml`, category: 'dictionary', content: tableXml, }, ...documentationFiles, ...licensingFiles, ...autoNumberFiles, ...overrideFiles, ...sysDictionaryFiles, ...sysDbObjectFiles, ], } }, }, sys_dictionary: { coalesce: ['name', 'element'], getUpdateName: (record) => ({ success: true, value: `sys_dictionary_${record.get('name').getValue()}_${record.get('element').getValue() || 'null'}`, }), }, sys_dictionary_override: { coalesce: ['name', 'element'], }, sys_documentation: { coalesce: ['name', 'element', 'language'], getUpdateName: (record) => ({ success: true, value: `sys_documentation_${record.get('name').getValue()}_${record.get('element').ifDefined()?.getValue() || ''}_${record.get('language').ifDefined()?.getValue() || 'en'}`, }), }, ua_table_licensing_config: { coalesce: ['name'], }, sys_index: { coalesce: ['logical_table_name', 'col_name_string'], // TODO: Need to implement getUpdateName() but the platform logic is pretty wild. Will take some effort. }, sys_number: { coalesce: ['category', 'prefix'], }, }, shapes: [ { shape: CallExpressionShape, fileTypes: ['fluent'], async toRecord(callExpression, { config, factory, transform, diagnostics, compiler }) { if (callExpression.getCallee() !== 'Table') { return { success: false } } const statement = callExpression .getOriginalNode() .getFirstAncestorByKind(ts.SyntaxKind.VariableStatement) const table = callExpression.getArgument(0).asObject().withAliasedKeys(tableAliases) generateDeprecatedDiagnostics(table, diagnostics) const relatedRecords: Record[] = [] const augments = table.get('augments').ifString() const isAugmentation = augments !== undefined const tableName = isAugmentation ? augments! : table.get('name').asString() if (!tableName.getValue().match(tableNameRegex)) { diagnostics.error( table.get('name'), 'Table name must only contain lowercase letters, numbers, and underscores and end with a letter or number' ) } let ignoreColumnNameCheck = false const scopeName = config.scope const scopePrefix = scopeName === 'global' ? 'u_' : `${scopeName}_` const prefixRegex = new RegExp(`^${scopePrefix}`) const tableNameMatch = tableName.getValue().match(prefixRegex) if (isAugmentation) { if (tableNameMatch && scopeName !== 'global') { const nameNode = tableName.getOriginalNode() if (!nameNode?.getParentIfKind(ts.SyntaxKind.AsExpression)) { diagnostics.error( table.get('augments'), `'augments' flag set on in-scope table '${tableName.getValue()}'` ) } else { ignoreColumnNameCheck = true } } } else if (!tableNameMatch && !isSNScope(scopeName) && scopeName !== 'global') { const nameNode = tableName.getOriginalNode() if (nameNode && !nameNode.getParentIfKind(ts.SyntaxKind.AsExpression)) { // 'sn' and 'now' scoped apps ignore this validation diagnostics.error( table.get('name'), `'name' property should start with scope prefix '${scopePrefix}'` ) } else { ignoreColumnNameCheck = true } } const declaration = callExpression.getOriginalNode().getParent() const exportedByName = declaration?.isKind(ts.SyntaxKind.VariableDeclaration) && declaration.isNamedExport() && declaration.getName() === tableName.getValue() if (!exportedByName) { diagnostics.error( callExpression, `Table definition should be exported as a named export with the name '${tableName.getValue()}'` ) } let anyNonPrefixedGlobalColumn = false if (!isAugmentation && scopeName === 'global' && !tableNameMatch) { const schema = table.get('schema').asObject() for (const [name, _] of schema.entries()) { if (!name.match(prefixRegex)) { anyNonPrefixedGlobalColumn = true break } } if (anyNonPrefixedGlobalColumn) { diagnostics.error( table.get('name'), `Global table 'name' property should start with custom prefix 'u_'` ) } } else if (scopeName === 'global' && !isAugmentation) { // Global table starts with custom prefix `u_`, allow any column name prefix ignoreColumnNameCheck = true } // sys_dictionary and sys_dictionary_override const schema = table.get('schema').asObject() const columnIdsMap = new Map() for (const [name, column] of schema.entries()) { if (!name.match(columnNameRegex)) { diagnostics.error( column.getOriginalNode().getParentIfKind(ts.SyntaxKind.PropertyAssignment) ?? column, 'Column name must only contain lowercase letters, numbers, and underscores' ) } // Check if this is an OverrideColumn if ( column.is(CallExpressionShape) && column.as(CallExpressionShape).getCallee() === 'OverrideColumn' ) { // This is an OverrideColumn() call - get the object from its first argument const columnObj = column.as(CallExpressionShape).getArgument(0).asObject() // Handle OverrideColumn - create sys_dictionary_override record // Validate that the table extends another table const extendsTable = table.get('extends').ifString()?.getValue() if (!extendsTable) { diagnostics.error( column, `Cannot use OverrideColumn in table '${tableName.getValue()}' because it does not extend another table` ) return { success: false } } // Use baseTable if provided, otherwise default to extends const baseTableValue = columnObj.get('baseTable') let baseTable: string if (baseTableValue.ifString()) { baseTable = baseTableValue.asString().getValue() } else { // Default to extends if baseTable not provided baseTable = extendsTable } // Create sys_dictionary_override record const overrideRecord = await factory.createRecord({ source: statement ?? callExpression, table: 'sys_dictionary_override', properties: columnObj.transform(({ $ }) => ({ name: $.val(tableName), element: $.val(name), base_table: $.val(baseTable), default_value: $.from('default'), default_value_override: $.from('default').map((v) => !!v.ifDefined()), calculation: $.from('calculation'), calculation_override: $.from('calculation').map((v) => !!v.ifDefined()), reference_qual: $.from('referenceQualifier'), reference_qual_override: $.from('referenceQualifier').map((v) => !!v.ifDefined()), read_only_option: $.from('readOnlyOption'), read_only_option_override: $.from('readOnlyOption').map((v) => !!v.ifDefined()), read_only: $.from('readOnlyOption').map((readOnlyOption) => { // read_only should be true if readOnlyOption has a value return !!readOnlyOption.ifDefined() }), read_only_override: $.from('readOnlyOption').map((v) => !!v.ifDefined()), dependent: $.from('dependent'), dependent_override: $.from('dependent').map((v) => !!v.ifDefined()), mandatory: $.from('mandatory').toBoolean().def(false), mandatory_override: $.from('mandatory').map((v) => !!v.ifDefined()), attributes: $.from('attributes').map((attrs) => { if (!attrs.isObject()) { return } const attrsObj = attrs.asObject().getValue() return Object.entries(attrsObj) .map(([key, value]) => `${key}=${value}`) .join(',') }), attributes_override: $.from('attributes').map((v) => !!v.ifDefined()), display_override: $.from('display').map((v) => { const boolShape = v.ifBoolean() if (!boolShape) { return false } return boolShape.getValue() === true }), })), }) relatedRecords.push(overrideRecord) } else { // Handle regular column - create sys_dictionary record if (isAugmentation && !tableNameMatch) { if (!isSNScope(scopeName) && !name.match(prefixRegex)) { diagnostics.error( column.getOriginalNode().getParentIfKind(ts.SyntaxKind.PropertyAssignment) ?? column, `Column name '${name}' must be prefixed with '${scopePrefix}' when augmenting a table` ) } } else if ( !ignoreColumnNameCheck && !tableNameMatch && !isSNScope(scopeName) && scopeName !== 'global' && !name.match(prefixRegex) ) { // 'sn' and 'now' scoped apps ignore this validation diagnostics.error( column.getOriginalNode().getParentIfKind(ts.SyntaxKind.PropertyAssignment) ?? column, `Column name should be prefixed with scope '${scopePrefix}' if table name does not contain prefix` ) } else if (scopeName === 'global' && !tableNameMatch && !name.match(prefixRegex)) { diagnostics.error( column.getOriginalNode().getParentIfKind(ts.SyntaxKind.PropertyAssignment) ?? column, `Column name should be prefixed with '${scopePrefix}' custom prefix if table name does not contain this prefix, such as when adding columns to an existing global table` ) } const display = table.get('display').ifString()?.getValue() === name const result = await transform.toRecord( addFieldsToColumn( { name, table: tableName.getValue(), display }, column.as(CallExpressionShape) ) ) if (!result.success) { diagnostics.error(column, 'Invalid column in table schema') return { success: false } } relatedRecords.push(result.value) columnIdsMap.set(name, result.value.getId().getValue()) } } // sys_index if (table.get('index').isArray()) { for (const index of table.get('index').asArray().getElements()) { const indexObject = index.asObject().getValue() const colNames = Array.isArray(indexObject['element']) ? indexObject['element'].join(',') : (indexObject['element'] as string) relatedRecords.push( await factory.createRecord({ source: statement ?? callExpression, table: 'sys_index', properties: { index_name: indexObject['name'], col_name_string: colNames, unique_index: indexObject['unique'] === true, logical_table_name: tableName.getValue(), index_col_name: colNames .split(',') .map((colName) => { if (columnIdsMap.has(colName)) { return columnIdsMap.get(colName) } if (!PLATFORM_COLUMNS.has(colName)) { diagnostics.warn( index, `Could not validate column '${colName}' on table '${tableName.getValue()}'; make sure it is a valid column on this table or one it extends` ) } return colName }) .join(','), }, }) ) } } // ua_table_licensing_config if (table.get('licensingConfig').isObject()) { const licenseObj = table.get('licensingConfig').asObject().withAliasedKeys(licensingAliases) generateDeprecatedDiagnostics(licenseObj, diagnostics) const licenseRecord = await factory.createRecord({ source: statement ?? callExpression, table: 'ua_table_licensing_config', properties: licenseObj.transform(({ $ }) => ({ name: $.val(table.get('name')), license_model: $.from('licenseModel').def('none'), owner_condition: $.from('ownerCondition'), license_condition: $.from('licenseCondition'), is_fulfillment: $.from('isFulfillment').def(false), op_delete: $.from('opDelete').def(true), op_update: $.from('opUpdate').def(true), license_roles: $.from('licenseRoles') .map((licenseRoles) => { return ( licenseRoles .ifArray() ?.getElements() .map((e) => e.asString().getValue()) .join(',') ?? [] ) }) .def([]), op_insert: $.from('opInsert').def(true), })), }) relatedRecords.push(licenseRecord) } else { relatedRecords.push( await factory.createRecord({ source: statement ?? callExpression, table: 'ua_table_licensing_config', properties: { name: tableName, license_model: 'none', is_fulfillment: false, op_delete: true, op_insert: true, op_update: true, }, }) ) } // sys_documentation const documentation = await labelShapeToDocumentation( table.get('label'), tableName.getValue(), undefined, config.defaultLanguage, factory, diagnostics ) relatedRecords.push(...documentation) // sys-number let numberRef: RecordId | undefined if (table.get('autoNumber').isObject()) { const autoNumber = table.get('autoNumber').asObject().withAliasedKeys(autoNumberAliases) const sysNumberRecord = await factory.createRecord({ source: statement ?? callExpression, table: 'sys_number', properties: autoNumber.transform(({ $ }) => ({ category: $.val(table.get('name').getValue()), maximum_digits: $.from('numberOfDigits').toNumber().def(7), number: $.def(1000), prefix: $.from('prefix').def('PRE'), })), }) numberRef = sysNumberRecord.getId() relatedRecords.push(sysNumberRecord) } // sys_dictionary (collection) if (!isAugmentation) { relatedRecords.push( await factory.createRecord({ source: statement ?? callExpression, table: 'sys_dictionary', properties: table.transform(({ $ }) => ({ name: $, element: $.val(undefined), internal_type: $.def('collection'), active: $.val(true), attributes: $.map((attributes) => { if (!attributes.isObject()) { return undefined } const attributesObj = attributes.asObject().getValue() return Object.entries(attributesObj) .map(([key, value]) => `${key}=${value}`) .join(',') }).def(''), audit: $.def(false), read_only: $.from('readOnly').def(false), text_index: $.from('textIndex').def(false), })), }) ) } const actionsShape = table.get('actions') const legacyActions = actionsShape.ifArray() if (legacyActions) { diagnostics.hint( actionsShape, `Passing 'actions' as an array is deprecated: it writes false for every action it ` + `omits. Use the object form, which writes only the actions you set, for example: ` + `actions: { read: true, create: false }.` ) } /** * Resolves one access flag from the `actions` config. The array form is a complete * enumeration - a listed action is `true`, an unlisted one `false`. The object form is * three-state, so a missing key stays undefined and the field is left unset. */ const resolveAction = (action: keyof TableActionAccess): boolean | undefined => { if (legacyActions) { return legacyActions .getElements() .some((element) => element.isString() && element.getValue() === action) } return actionsShape.ifObject()?.get(action).ifBoolean()?.getValue() } const ext = table.get('extends') const parentReference = ext.isString() ? await factory.createReference({ source: ext, table: 'sys_db_object', keys: { name: ext }, }) : ext.ifDefined()?.toRecordId() const userRoleField = table.get('userRole') const userRoleReference = userRoleField.isString() ? userRoleField.getValue() === '' ? undefined : await factory.createReference({ source: userRoleField, table: 'sys_user_role', keys: { name: userRoleField }, }) : userRoleField.ifDefined()?.toRecordId() // sys_db_object const tableRecord = await factory.createRecord({ source: statement ?? callExpression, table: 'sys_db_object', properties: table.transform(({ $ }) => ({ client_scripts_access: $.from('allowClientScripts').toBoolean(), alter_access: $.from('allowNewFields').toBoolean(), actions_access: $.from('allowUiActions').toBoolean(), ws_access: $.from('allowWebServiceAccess').toBoolean(), number_ref: $.val(numberRef), access: $.from('accessibleFrom').def('public'), caller_access: $.from('callerAccess').map((callerAccess) => { const value = callerAccess.ifString()?.getValue() if (!value) { return undefined } const index = callerAccessLevels.indexOf(value as 'none' | 'tracking' | 'restricted') // index 0 ('none') is the platform default — emit empty , matching stock. return index > 0 ? index : undefined }), super_class: $.val(parentReference), read_access: $.val(resolveAction('read')), update_access: $.val(resolveAction('update')), delete_access: $.val(resolveAction('delete')), create_access: $.val(resolveAction('create')), is_extendable: $.from('extensible').toBoolean().def(false), label: $.val( getLabelForDefaultLanguage(documentation, config.defaultLanguage) ?? generateLabel(tableName.getValue()) ), live_feed_enabled: $.from('liveFeed').toBoolean().def(false), name: $.from('name', 'augments').map( (nameVal, augmentsVal) => augmentsVal.ifString() ?? nameVal ), create_access_controls: $.from('createAccessControls').toBoolean().def(false), user_role: $.val(userRoleReference), scriptable_table: $.from('scriptableTable').toBoolean().def(false), // Controls toFile output, not written to record XML augments: $.from('augments'), })), }) const tableAccessPolicyFields = [ 'ws_access', 'actions_access', 'alter_access', 'client_scripts_access', 'read_access', 'update_access', 'delete_access', 'create_access', ] for (const field of tableAccessPolicyFields) { tableRecord.get(field).omitFromXmlIfUndefined() } if (exportedByName) { addTableToGlobalGeneratedFile(table, callExpression.getOriginalFilePath(), compiler) } return { success: true, value: tableRecord.with(...relatedRecords), } }, }, ], nodes: [ { node: 'VariableStatement', fileTypes: ['fluent'], entryPoint: true, }, { node: 'ExportAssignment', fileTypes: ['fluent'], entryPoint: true, toShape(node, { diagnostics }) { const expression = node.getExpression().asKind(ts.SyntaxKind.CallExpression)?.getExpression() if (expression && expression.getText() === 'Table') { diagnostics.error(expression, 'Export assignments are not supported for Table definitions') } return { success: false } }, }, ], }) function parseTableBootstrapXml(xml: unknown): TableDefinition | null { const parsedXml = BootstrapDatabaseSchema.safeParse(xml) if (!parsedXml.success) { return null } const table = parsedXml.data.element const columns: ColumnDefinition[] = [] if (table.element) { const mapColumnToDefinition = (column: z.infer): ColumnDefinition => ({ name: column['@_name'], type: column['@_internal_type'] ?? column['@_type'], defaultValue: column['@_default'] ?? column['@_default_value'], audit: column['@_audit'], maxLength: column['@_max_length'], isMandatory: column['@_mandatory'], isReadOnly: column['@_read_only'], readOnlyOption: column['@_read_only_option'], referenceTable: column['@_reference_table'], referenceQual: column['@_reference_qual'], columnLabel: column['@_label'], choices: column.choice?.element ? Array.isArray(column.choice.element) ? column.choice.element.map((choice) => ({ label: choice['@_label'], value: choice['@_value'] ?? '', sequence: choice['@_sequence'], dependentValue: choice['@_dependent_value'], hint: choice['@_hint'], inactive: choice['@_inactive'], inactiveOnUpdate: choice['@_inactive_on_update'], language: choice['@_language'], })) : [ { label: column.choice.element['@_label'], value: column.choice.element['@_value'] ?? '', sequence: column.choice.element['@_sequence'], dependentValue: column.choice.element['@_dependent_value'], hint: column.choice.element['@_hint'], inactive: column.choice.element['@_inactive'], inactiveOnUpdate: column.choice.element['@_inactive_on_update'], language: column.choice.element['@_language'], }, ] : undefined, isActive: column['@_active'], display: column['@_display'], useDependentField: column['@_use_dependent_field'], useDynamicDefault: column['@_use_dynamic_default'], reference: column['@_reference'], isVirtual: column['@_virtual'], formula: column['@_formula'], virtualType: column['@_virtual_type'] === 'script' || column['@_virtual_type'] === 'formula' ? column['@_virtual_type'] : undefined, useReferenceQualifier: column['@_use_reference_qualifier'] === 'simple' || column['@_use_reference_qualifier'] === 'dynamic' || column['@_use_reference_qualifier'] === 'advanced' ? column['@_use_reference_qualifier'] : undefined, dynamicRefQual: column['@_dynamic_ref_qual'], calculation: column['@_calculation'], choiceField: column['@_choice_field'], functionDefinition: column['@_function_definition'], referenceFloats: column['@_reference_floats'], referenceKey: column['@_reference_key'], mtom: column['@_mtom'], dynamicCreationScript: column['@_dynamic_creation_script'], dynamicCreation: column['@_dynamic_creation'], plural: column['@_plural'], hint: column['@_hint'], help: column['@_help'], url: column['@_url'], urlTarget: column['@_url_target'], widget: column['@_widget'], tableReference: column['@_table_reference'], elementReference: column['@_element_reference'], unique: column['@_unique'], spellCheck: column['@_spell_check'], xmlView: column['@_xml_view'], isArray: column['@_array'], textIndex: column['@_text_index'], isPrimary: column['@_primary'], attributes: column['@_attributes'], referenceCascadeRule: column['@_reference_cascade_rule'], choiceValue: column['@_choice'] ?? column['@_choice_type'], choiceTable: column['@_choice_table'], dependent: column['@_dependent'], }) if (Array.isArray(table.element)) { columns.push(...table.element.map(mapColumnToDefinition)) } else { columns.push(mapColumnToDefinition(table.element)) } } const indexes: IndexDefinition[] = [] if (table.index) { const mapIndexToDefinition = (index: z.infer): IndexDefinition => ({ name: index['@_name'], unique: index['@_unique'], columns: index.element ? Array.isArray(index.element) ? index.element.map((element) => element['@_name']) : [index.element['@_name']] : [], }) if (Array.isArray(table.index)) { indexes.push(...table.index.map(mapIndexToDefinition)) } else { indexes.push(mapIndexToDefinition(table.index)) } } return { name: table['@_name'], label: table['@_label'], plural: table['@_plural'], hint: table['@_hint'], help: table['@_help'], url: table['@_url'], urlTarget: table['@_url_target'], extends: table['@_extends'], columns, indexes, audit: table['@_audit'], readOnly: table['@_read_only'], textIndex: table['@_text_index'], clientScriptsAccess: table['@_client_scripts_access'], wsAccess: table['@_ws_access'], display: table['@_display'], alterAccess: table['@_alter_access'], createAccess: table['@_create_access'], deleteAccess: table['@_delete_access'], updateAccess: table['@_update_access'], callerAccess: table['@_caller_access'], actionsAccess: table['@_actions_access'], readAccess: table['@_read_access'], isExtendable: table['@_is_extendable'], createAccessControls: table['@_create_access_controls'], userRole: table['@_user_role'], scriptableTable: table['@_scriptable_table'], attributes: table['@_attributes'], } } function tableDefToRecordProperties( tableDef: TableDefinition, defaultLanguage: string = 'en' ): { sysDbObject: SysDbObjectProperties sysDictionary: SysDictionaryProperties[] sysChoice: SysChoiceProperties[] sysIndex: SysIndexProperties[] sysDocumentation: SysDocumentationProperties[] sysDictionaryOverride: SysDictionaryOverrideProperties[] } { const sysDbObject: SysDbObjectProperties = { name: tableDef.name, label: tableDef.label, super_class: tableDef.extends, is_extendable: tableDef.isExtendable, create_access_controls: tableDef.createAccessControls, user_role: tableDef.userRole, scriptable_table: tableDef.scriptableTable, client_scripts_access: tableDef.clientScriptsAccess, ws_access: tableDef.wsAccess, alter_access: tableDef.alterAccess, create_access: tableDef.createAccess, delete_access: tableDef.deleteAccess, update_access: tableDef.updateAccess, caller_access: tableDef.callerAccess, actions_access: tableDef.actionsAccess, read_access: tableDef.readAccess, } const sysDictionary: SysDictionaryProperties[] = [] const sysChoice: SysChoiceProperties[] = [] const sysDocumentation: SysDocumentationProperties[] = [] const sysDictionaryOverride: SysDictionaryOverrideProperties[] = [] // table documentation const tableLabel = tableDef.label || generateLabel(tableDef.name) sysDocumentation.push({ name: tableDef.name, element: undefined, label: tableLabel, plural: tableDef.plural || generatePlural(tableLabel), language: defaultLanguage, hint: tableDef.hint, help: tableDef.help, url: tableDef.url, url_target: tableDef.urlTarget, }) for (const column of tableDef.columns) { sysDictionary.push({ name: tableDef.name, element: column.name, internal_type: column.type || 'string', default_value: column.defaultValue, max_length: column.maxLength, mandatory: column.isMandatory, read_only: column.isReadOnly, read_only_option: column.readOnlyOption, reference_table: column.referenceTable, reference_qual: column.referenceQual, column_label: column.columnLabel, active: column.isActive, display: tableDef.display === column.name || column.display, use_dependent_field: column.useDependentField, use_dynamic_default: column.useDynamicDefault, reference: column.reference, virtual: column.isVirtual, formula: column.formula, virtual_type: column.virtualType, use_reference_qualifier: column.useReferenceQualifier, dynamic_ref_qual: column.dynamicRefQual, default: column.defaultValue, calculation: column.calculation, choice_field: column.choiceField, function_definition: column.functionDefinition, reference_floats: column.referenceFloats, reference_key: column.referenceKey, mtom: column.mtom, dynamic_creation_script: column.dynamicCreationScript, dynamic_creation: column.dynamicCreation, plural: column.plural, hint: column.hint, help: column.help, url: column.url, url_target: column.urlTarget, widget: column.widget, table_reference: column.tableReference, element_reference: column.elementReference, unique: column.unique, spell_check: column.spellCheck, xml_view: column.xmlView, array: column.isArray, text_index: column.textIndex, primary: column.isPrimary, attributes: column.attributes, reference_cascade_rule: column.referenceCascadeRule, choice: column.choiceValue, choice_table: column.choiceTable, dependent: column.dependent, function_field: column.functionDefinition ? true : undefined, audit: column.audit, }) if (column.choices?.length) { for (const choice of column.choices) { sysChoice.push({ name: tableDef.name, element: column.name, label: choice.label || choice.value, value: choice.value, sequence: choice.sequence, dependent_value: choice.dependentValue, hint: choice.hint, inactive: choice.inactive, inactive_on_update: choice.inactiveOnUpdate, language: choice.language ?? defaultLanguage, }) } } const label = column.columnLabel || generateLabel(column.name) // Add column documentation sysDocumentation.push({ name: tableDef.name, element: column.name, label, plural: column.plural || generatePlural(label), language: defaultLanguage, hint: column.hint, help: column.help, url: column.url, url_target: column.urlTarget, }) } // sys_dictionary collection record sysDictionary.push({ name: tableDef.name, internal_type: 'collection', // table properties attributes: tableDef.attributes, audit: tableDef.audit, text_index: tableDef.textIndex, read_only: tableDef.readOnly, // column properties left undefined element: undefined, default_value: undefined, max_length: undefined, mandatory: undefined, read_only_option: undefined, reference_table: undefined, reference_qual: undefined, column_label: undefined, active: undefined, display: undefined, use_dependent_field: undefined, use_dynamic_default: undefined, reference: undefined, virtual: undefined, formula: undefined, virtual_type: undefined, use_reference_qualifier: undefined, dynamic_ref_qual: undefined, default: undefined, calculation: undefined, choice_field: undefined, function_definition: undefined, reference_floats: undefined, reference_key: undefined, mtom: undefined, dynamic_creation_script: undefined, dynamic_creation: undefined, plural: undefined, hint: undefined, help: undefined, url: undefined, url_target: undefined, widget: undefined, table_reference: undefined, element_reference: undefined, unique: undefined, spell_check: undefined, xml_view: undefined, array: undefined, primary: undefined, reference_cascade_rule: undefined, choice: undefined, choice_table: undefined, dependent: undefined, function_field: undefined, }) const sysIndex: SysIndexProperties[] = [] for (const index of tableDef.indexes) { sysIndex.push({ ...(index.name !== undefined ? { index_name: index.name } : {}), col_name_string: index.columns.join(','), unique_index: index.unique, logical_table_name: tableDef.name, }) } return { sysDbObject, sysDictionary, sysChoice, sysIndex, sysDocumentation, sysDictionaryOverride, } } function addTableToGlobalGeneratedFile(tableArg: ObjectShape, sourceFilePath: string, compiler: Compiler): void { const { interfaces, properties, imports, namedImports } = { interfaces: [] as ts.OptionalKind[], properties: [] as ts.OptionalKind[], imports: {} as GlobalRecord>, namedImports: [] as Array< ts.OptionalKind & { existingImport: ts.ImportDeclaration } >, } const generatedTableFile: ts.SourceFile | undefined = compiler.getGeneratedTableFile() const tableName = tableArg.get('augments').ifString()?.getValue() ?? tableArg.get('name').asString().getValue() if (!(tableName.trim().length > 0 && tableName.match(tableNameRegex))) { return } const extendsTable = tableArg.get('extends').ifString()?.getValue() if (!compiler.interfaceExistsInGlobalDeclaration(tableName)) { interfaces.push({ name: tableName, extends: [`Helper`], }) } if (!compiler.propertyExistsInGlobalDeclaration(tableName)) { const interfaceType = extendsTable ? `Table` : `Table` properties.push({ name: tableName, type: interfaceType, }) } if (generatedTableFile) { const resolvedModuleSpecifier = generatedTableFile.getRelativePathAsModuleSpecifierTo(sourceFilePath) if (resolvedModuleSpecifier) { const importExists = compiler.importExistsInGlobalDeclaration(resolvedModuleSpecifier) if (!importExists) { if (imports[sourceFilePath]) { const existingImport = imports[sourceFilePath] if (Array.isArray(existingImport.namedImports)) { ;(existingImport.namedImports as string[]).push(tableName) } } else { imports[sourceFilePath] = { namedImports: [tableName], moduleSpecifier: resolvedModuleSpecifier, } } } else { const namedImportExists = importExists?.getNamedImports().some((value) => value.getName() === tableName) if (!namedImportExists) { namedImports.push({ existingImport: importExists, name: tableName, }) } } } } if (interfaces.length > 0 || properties.length > 0 || Object.keys(imports).length > 0 || namedImports.length > 0) { compiler.addTableInterfacesToGlobalDeclaration({ interfaces, properties, imports, namedImports, }) } } /** * Records a bootstrap-derived Table() shape into $$GENERATED$$_bootstrapped_tables.ts so that other plugins (e.g. RecordPlugin) * can resolve Data column types via TypeScript during the same transform pass — no sys_dictionary query needed. * * The `declare global` augmentation lives inside the bootstrapped file (referencing its LOCAL `typeof tableName` export) rather * than the common table file. That avoids the duplicate-import error when a pre-existing Fluent Table() of the same name was * already wired into the common file, and TypeScript still merges the two TableSchemas interface declarations so both * contribute their columns to the resolved Data. */ function registerBootstrappedTable( tableCall: CallExpressionShape, schema: { [key: string]: CallExpressionShape }, compiler: Compiler ): void { const file = compiler.getGeneratedBootstrappedTablesFile() if (!file || Object.keys(schema).length === 0) { return } const tableArg = tableCall.getArgument(0).asObject() const tableName = tableArg.get('augments').ifString()?.getValue() ?? tableArg.get('name').asString().getValue() const tableSchemas = file.getModule('global')?.getModule('Now')?.getModule('Internal')?.getModule('TableSchemas') const tables = file.getModule('global')?.getModule('Now')?.getModule('Internal')?.getInterface('Tables') if (!tableSchemas || !tables) { return } const coreImport = file.getImportDeclaration('@servicenow/sdk/core') const existingImports = new Set(coreImport?.getNamedImports().map((n) => n.getName())) for (const callee of new Set(Object.values(schema).map((s) => s.getCallee()))) { if (!existingImports.has(callee)) { coreImport?.addNamedImport(callee) } } // Replace any existing export (re-transform of same XML), then write the shape's rendered code. file.getVariableDeclaration(tableName)?.getVariableStatement()?.remove() file.addVariableStatement({ isExported: true, declarationKind: ts.VariableDeclarationKind.Const, declarations: [{ name: tableName, initializer: tableCall.getCode() }], }) if (!tableSchemas.getInterface(tableName)) { tableSchemas.addInterface({ name: tableName, extends: [`Helper`] }) } if (!tables.getProperty(tableName)) { tables.addProperty({ name: tableName, type: `Table` }) } } function filterUndefinedProperties(obj: T): T { return Object.fromEntries(Object.entries(obj).filter(([_, value]) => value !== undefined)) as T } function filterUndefinedAttributes(attributes: [string, T | undefined][]): [string, T][] { return attributes.filter((pair): pair is [string, T] => pair[1] !== undefined) } function createElement(name: string, attributes: [string, string][] = [], children: XMLBuilder[] = []): XMLBuilder { const element = create().ele(name) for (const [key, value] of attributes) { const sanitizedValue = value.replaceAll('&', '&').replaceAll('\n', ' ').replaceAll('\r', ' ') element.att(key, sanitizedValue) } for (const child of children) { element.import(child) } return element } async function generateRecordXml(records: Record[], excludeFields: string[] = []): Promise { const files: OutputFile[] = [] for (const record of records) { const recordBuilder = unloadBuilder(record.getTable()) const builder = recordBuilder.record(record) record .entries() .sort(([a], [b]) => a.localeCompare(b)) .filter(([prop]) => excludeFields.length === 0 || !excludeFields.includes(prop)) .forEach(([prop, shape]) => builder.field(prop, shape)) files.push({ source: record, name: `${record.get('sys_update_name').getValue()}.xml`, category: record.getInstallCategory(), content: recordBuilder.end(), }) } return files } function isDefaultLicenseConfig(tableName: string, licenseRecord: Record): boolean { const license = licenseRecord.asObject().getValue() as globalThis.Record const defaultRecord: globalThis.Record = { name: tableName, license_model: 'none', is_fulfillment: false, op_delete: true, op_insert: true, op_update: true, } const optionalFields = ['owner_condition', 'license_condition', 'license_roles'] as const optionalFields.forEach((field) => { if (field in license) { defaultRecord[field] = '' } }) const licenseFieldsToCompare = Object.fromEntries( Object.entries(license).filter(([k, _v]) => !k.startsWith('sys_')) ) return isEqual(defaultRecord, licenseFieldsToCompare) }