/* Copyright (c) 2022 Betim Beja and Shko Online LLC Licensed under the MIT license. */ import type { SinonStub } from 'sinon'; import type { ShkoOnline } from '../../ShkoOnline'; import type { AttributeDB } from './SQLQueries'; import alasql from 'alasql'; import { stub } from 'sinon'; import { AttributeMetadataSQL, EntityMetadataSQL, OptionSetMetadataSQL } from './SQLQueries'; import { getAttributeTypeFromString } from './getAttributeTypeFromString'; import { getSqlTypeForAttribute } from './getSQLTypeForAttribute'; import { AttributeType, OptionSetType } from '../../ComponentFramework-Mock'; import { SavedQueryMetadata } from './PlatformMetadata/SavedQuery.Metadata'; import { UserQueryMetadata } from './PlatformMetadata/UserQuery.Metadata'; import { ODataQuery, FilterOperator } from '@shko.online/dataverse-odata'; export class MetadataDB { static readonly CanvasLogicalName = '!CanvasApp'; static Collisions = 0; /** * Setting this to `true` will warn for missing metadata when doing operations. */ _warnMissingInit: boolean; db: { databaseid: string; exec: typeof alasql }; _newId: SinonStub<[], string>; EntityMetadataSQL: EntityMetadataSQL; OptionSetMetadataSQL: OptionSetMetadataSQL; AttributeMetadataSQL: AttributeMetadataSQL; constructor() { this._warnMissingInit = false; this.db = new (alasql as any).Database(); this.AttributeMetadataSQL = new AttributeMetadataSQL(this.db.exec.bind(this.db)); this.EntityMetadataSQL = new EntityMetadataSQL(this.db.exec.bind(this.db)); this.OptionSetMetadataSQL = new OptionSetMetadataSQL(this.db.exec.bind(this.db)); this._newId = stub(); this._newId.callsFake(() => this.db.exec<{ID:string}[]>('SELECT NEWID() as ID')[0]['ID']); } createAttribute(entityId: string, attribute: ShkoOnline.AttributeMetadata) { if (!attribute.MetadataId) attribute.MetadataId = this._newId(); let OptionSetId: string | undefined = undefined; if (attribute.AttributeType === AttributeType.Picklist) { const optionsetAttribute = attribute as ShkoOnline.PickListAttributeMetadata; if (!optionsetAttribute.OptionSet) { optionsetAttribute.OptionSet = { MetadataId: '', IsCustomOptionSet: false, Name: optionsetAttribute.LogicalName, Options: {}, OptionSetType: OptionSetType.Picklist, }; } if (!optionsetAttribute.OptionSet.MetadataId) { optionsetAttribute.OptionSet.MetadataId = this._newId(); } OptionSetId = optionsetAttribute.OptionSet.MetadataId; this.OptionSetMetadataSQL.AddOptionSetMetadata({ IsCustomOptionSet: optionsetAttribute.OptionSet.IsCustomOptionSet, LogicalName: optionsetAttribute.OptionSet.Name, OptionSetId, OptionSetType: optionsetAttribute.OptionSet.OptionSetType, }); for (let optionValue in optionsetAttribute.OptionSet.Options) { const option = optionsetAttribute.OptionSet.Options[optionValue]; const optionId = this._newId(); this.OptionSetMetadataSQL.AddOptionMetadata({ OptionId: optionId, OptionSetId, Color: option.Color, Label: option.Label, Value: option.Value, }); } } else if (attribute.AttributeType === AttributeType.Boolean) { const booleanAttribute = attribute as ShkoOnline.BooleanAttributeMetadata; if (!booleanAttribute.OptionSet) { booleanAttribute.OptionSet = { MetadataId: '', IsCustomOptionSet: false, Name: booleanAttribute.LogicalName, OptionSetType: OptionSetType.Boolean, DisplayName: '', FalseOption: { Color: '', Label: 'No', Value: 0, }, TrueOption: { Color: '', Label: 'Yes', Value: 1, }, }; } if (!booleanAttribute.OptionSet.FalseOption) { booleanAttribute.OptionSet.FalseOption = { Color: '', Label: 'No', Value: 0, }; } if (!booleanAttribute.OptionSet.TrueOption) { booleanAttribute.OptionSet.TrueOption = { Color: '', Label: 'Yes', Value: 1, }; } if (!booleanAttribute.OptionSet.MetadataId) { booleanAttribute.OptionSet.MetadataId = this._newId(); } OptionSetId = booleanAttribute.OptionSet.MetadataId; this.OptionSetMetadataSQL.AddOptionSetMetadata({ IsCustomOptionSet: booleanAttribute.OptionSet.IsCustomOptionSet, LogicalName: booleanAttribute.OptionSet.Name, OptionSetId, OptionSetType: booleanAttribute.OptionSet.OptionSetType, }); let OptionId = this._newId(); let option = booleanAttribute.OptionSet.FalseOption; this.OptionSetMetadataSQL.AddOptionMetadata({ OptionId, OptionSetId, Color: option.Color, Label: option.Label, Value: option.Value, }); OptionId = this._newId(); option = booleanAttribute.OptionSet.TrueOption; this.OptionSetMetadataSQL.AddOptionMetadata({ OptionId, OptionSetId, Color: option.Color, Label: option.Label, Value: option.Value, }); } this.AttributeMetadataSQL.AddAttributeMetadata({ EntityId: entityId, AttributeId: attribute.MetadataId, LogicalName: attribute.LogicalName, AttributeType: attribute.AttributeType, DefaultFormValue: (attribute as ShkoOnline.PickListAttributeMetadata).DefaultFormValue, OptionSetId, AttributeOf: attribute.AttributeOf, AttributeTypeName: attribute.AttributeTypeName?.value || '', }); } /** * Use this method to initialize the metadata * @param metadatas */ initMetadata(metadatas: ShkoOnline.EntityMetadata[]) { metadatas.forEach((metadata) => this.initSingleTable(metadata)); } private initSingleTable(metadata: ShkoOnline.EntityMetadata) { const entityId = this._newId(); const safeTableName = metadata.LogicalName.toLowerCase().replace(/!/g, '_').replace(/@/g, '_'); if (!metadata.Attributes) { metadata.Attributes = []; } let attributes = metadata.Attributes; attributes.forEach((attribute) => { attribute.AttributeType = getAttributeTypeFromString(attribute.AttributeType); }); if (!metadata.PrimaryIdAttribute) { let primaryAttribute = attributes.find((attr) => attr.AttributeType === AttributeType.Uniqueidentifier); if (primaryAttribute) { metadata.PrimaryIdAttribute = primaryAttribute.LogicalName; } else { metadata.PrimaryIdAttribute = safeTableName + 'id'; } } if (!metadata.PrimaryNameAttribute) { let primaryAttribute = attributes.find((attr) => attr.AttributeType === AttributeType.EntityName); if (primaryAttribute) { metadata.PrimaryNameAttribute = primaryAttribute.LogicalName; } else { metadata.PrimaryNameAttribute = 'name'; } } if (!attributes.find((attr) => attr.LogicalName === metadata.PrimaryIdAttribute)) { attributes.push({ AttributeType: AttributeType.Uniqueidentifier, LogicalName: metadata.PrimaryIdAttribute, IsPrimaryId: true, } as ShkoOnline.AttributeMetadata); } const virtualAttributes: ShkoOnline.AttributeMetadata[] = []; attributes.forEach((attr) => { const virtualAttribute = attr.LogicalName + 'name'; if ( !( attr.AttributeType === AttributeType.Boolean || attr.AttributeType === AttributeType.Lookup || attr.AttributeType === AttributeType.Picklist ) || attributes.some((attrv) => attrv.LogicalName === virtualAttribute) ) { return; } virtualAttributes.push({ AttributeOf: attr.LogicalName, AttributeType: AttributeType.Virtual, LogicalName: virtualAttribute, SchemaName: (attr.SchemaName || attr.LogicalName) + 'Name', } as ShkoOnline.AttributeMetadata); }); metadata.Attributes = attributes.concat(virtualAttributes); attributes = metadata.Attributes; if (this.EntityMetadataSQL.SelectTableMetadata(metadata.LogicalName).length == 0) { this.EntityMetadataSQL.AddEntityMetadata({ EntityId: entityId, EntitySetName: metadata.EntitySetName, LogicalName: metadata.LogicalName, PrimaryIdAttribute: metadata.PrimaryIdAttribute, PrimaryNameAttribute: metadata.PrimaryNameAttribute, PrimaryImageAttribute: metadata.PrimaryImageAttribute, DisplayName: metadata.DisplayName, DisplayCollectionName: metadata.DisplayCollectionName, LogicalCollectionName: metadata.LogicalCollectionName, Description: metadata.Description, }); const columns: string[] = []; attributes.forEach((attribute) => { this.createAttribute(entityId, attribute); columns.push(' [' + attribute.LogicalName + '] ' + getSqlTypeForAttribute(attribute.AttributeType)); if (attribute.AttributeType === AttributeType.Lookup) { columns.push(' [' + attribute.LogicalName + 'type] string'); columns.push(' [' + attribute.LogicalName + 'navigation] string'); } }); // Create Table const createTableQuery = `CREATE TABLE ${safeTableName} (${columns.join(',')})`; this.db.exec(createTableQuery); } else { attributes.forEach((attribute) => { this.upsertAttributeMetadata(metadata.LogicalName, attribute); }); } } /** * Get the metadata for a specific attribute * @param entity The target table * @param attribute The target attribute */ getAttributeMetadata(entity: string, attribute: string) { const tableMetadataDB = this.EntityMetadataSQL.SelectTableMetadata(entity); if (!tableMetadataDB || tableMetadataDB.length === 0) { if (this._warnMissingInit) { console.warn(`Missing init for ${entity}`); } return; } const tableMetadata = { LogicalName: tableMetadataDB[0].LogicalName, EntitySetName: tableMetadataDB[0].EntitySetName, Attributes: [], PrimaryIdAttribute: tableMetadataDB[0].PrimaryIdAttribute, PrimaryNameAttribute: tableMetadataDB[0].PrimaryNameAttribute, PrimaryImageAttribute: tableMetadataDB[0].PrimaryImageAttribute, DisplayName: tableMetadataDB[0].DisplayName, DisplayCollectionName: tableMetadataDB[0].DisplayCollectionName, LogicalCollectionName: tableMetadataDB[0].LogicalCollectionName, Description: tableMetadataDB[0].Description, } as ShkoOnline.EntityMetadata; const resultDB = this.AttributeMetadataSQL.SelectAttributeMetadata(attribute, entity); if (!resultDB || resultDB.length === 0) { if (this._warnMissingInit) { console.warn(`Missing init for ${entity} ${attribute}`); } return null; } return this.mapAttributeFromAttributeDB(tableMetadata, resultDB[0]); } /** * Get the metadata for a table * @param entity The target table */ getTableMetadataByEntitySet(entitySetName: string) { const tableMetadataDB = this.EntityMetadataSQL.SelectTableMetadataByEntitySet(entitySetName); if (!tableMetadataDB || tableMetadataDB.length === 0) { if (this._warnMissingInit) { console.warn(`Missing init for entitySet ${entitySetName}`); } return; } return this.getTableMetadata(tableMetadataDB[0].LogicalName); } /** * Get the metadata for a table * @param entity The target table */ getTableMetadata(entity: string) { const tableMetadataDB = this.EntityMetadataSQL.SelectTableMetadata(entity); if (!tableMetadataDB || tableMetadataDB.length === 0) { if (this._warnMissingInit) { console.warn(`Missing init for ${entity}`); } return; } const tableMetadata = { LogicalName: tableMetadataDB[0].LogicalName, EntitySetName: tableMetadataDB[0].EntitySetName, Attributes: [], PrimaryIdAttribute: tableMetadataDB[0].PrimaryIdAttribute, PrimaryNameAttribute: tableMetadataDB[0].PrimaryNameAttribute, PrimaryImageAttribute: tableMetadataDB[0].PrimaryImageAttribute, DisplayName: tableMetadataDB[0].DisplayName, DisplayCollectionName: tableMetadataDB[0].DisplayCollectionName, LogicalCollectionName: tableMetadataDB[0].LogicalCollectionName, Description: tableMetadataDB[0].Description, } as ShkoOnline.EntityMetadata; const attributesDB = this.AttributeMetadataSQL.SelectAttributeMetadataForTable(entity); attributesDB.forEach((attributeDB) => { const attribute = this.mapAttributeFromAttributeDB(tableMetadata, attributeDB); tableMetadata.Attributes?.push(attribute); }); return tableMetadata; } private mapAttributeFromAttributeDB(tableMetadata: ShkoOnline.EntityMetadata, attributeDB: AttributeDB) { const attribute = { AttributeType: attributeDB.AttributeType, EntityLogicalName: tableMetadata.LogicalName, MetadataId: attributeDB.AttributeId, LogicalName: attributeDB.LogicalName, AttributeOf: attributeDB.AttributeOf, AttributeTypeName: { value: attributeDB.AttributeTypeName, }, IsPrimaryId: attributeDB.LogicalName === tableMetadata.PrimaryIdAttribute, IsPrimaryName: attributeDB.LogicalName === tableMetadata.PrimaryNameAttribute, } as ShkoOnline.AttributeMetadata; if (attribute.AttributeType === AttributeType.Picklist) { const optionsetDB = this.OptionSetMetadataSQL.SelectOptionSetMetadata(attributeDB.OptionSetId || ''); (attribute as ShkoOnline.PickListAttributeMetadata).DefaultFormValue = attributeDB.DefaultFormValue as number; (attribute as ShkoOnline.PickListAttributeMetadata).OptionSet = { IsCustomOptionSet: optionsetDB[0].IsCustomOptionSet, MetadataId: optionsetDB[0].OptionSetId, Name: optionsetDB[0].LogicalName, OptionSetType: optionsetDB[0].OptionSetType, Options: {}, }; const optionsDB = this.OptionSetMetadataSQL.SelectOptionSetOptionMetadata(attributeDB.OptionSetId || ''); optionsDB.forEach((option) => { (attribute as ShkoOnline.PickListAttributeMetadata).OptionSet.Options[option.Value] = { Label: option.Label, Value: option.Value, Color: option.Color, }; }); } else if (attribute.AttributeType === AttributeType.Boolean) { const optionsetDB = this.OptionSetMetadataSQL.SelectOptionSetMetadata(attributeDB.OptionSetId || ''); (attribute as ShkoOnline.BooleanAttributeMetadata).OptionSet = { IsCustomOptionSet: optionsetDB[0].IsCustomOptionSet, MetadataId: optionsetDB[0].OptionSetId, Name: optionsetDB[0].LogicalName, OptionSetType: optionsetDB[0].OptionSetType, DisplayName: '', FalseOption: { Color: '', Label: 'No', Value: 0, }, TrueOption: { Color: '', Label: 'Yes', Value: 1, }, }; const optionsDB = this.OptionSetMetadataSQL.SelectOptionSetOptionMetadata(attributeDB.OptionSetId || ''); optionsDB.forEach((option) => { (attribute as ShkoOnline.BooleanAttributeMetadata).OptionSet[ option.Value ? 'TrueOption' : 'FalseOption' ] = { Label: option.Label, Value: option.Value, Color: option.Color, }; }); } return attribute; } /** * Update or Insert the metadata for a specific column of a table * @param entity The target table * @param attributeMetadata The target column * @returns */ upsertAttributeMetadata(entity: string, attributeMetadata: ShkoOnline.AttributeMetadata) { const tableDB = this.EntityMetadataSQL.SelectTableMetadata(entity); if (!tableDB) { console.warn(`Could not find metadata for ${entity}`); return; } const safeTableName = tableDB[0].LogicalName.toLowerCase().replace(/!/g, '_').replace(/@/g, '_'); let attributeDB = this.AttributeMetadataSQL.SelectAttributeMetadata(attributeMetadata.LogicalName, entity); if (attributeMetadata.MetadataId) { attributeDB = attributeDB.concat( this.AttributeMetadataSQL.SelectAttributeMetadataById(attributeMetadata.MetadataId), ); } if (attributeDB && attributeDB.length > 0) { attributeDB.forEach((attribute) => { if ( (attributeMetadata.AttributeType === AttributeType.Boolean || attributeMetadata.AttributeType === AttributeType.Lookup || attributeMetadata.AttributeType === AttributeType.Picklist || attributeMetadata.AttributeType === AttributeType.State || attributeMetadata.AttributeType == AttributeType.Status) && attribute.OptionSetId ) { this.db.exec('DELETE FROM Metadata__Optionset WHERE OptionSetId = ?', [attribute.OptionSetId]); this.db.exec('DELETE FROM Metadata__Optionset_Option WHERE OptionSetId = ?', [ attribute.OptionSetId, ]); } if (attribute.AttributeId) this.db.exec('DELETE FROM Metadata__Attribute WHERE AttributeId = ?', [attribute.AttributeId]); }); } const virtualAttribute = attributeMetadata.LogicalName + 'name'; this.createAttribute(tableDB[0].EntityId, attributeMetadata); if ( attributeMetadata.AttributeType === AttributeType.Boolean || attributeMetadata.AttributeType === AttributeType.Lookup || attributeMetadata.AttributeType === AttributeType.Picklist || attributeMetadata.AttributeType === AttributeType.State || attributeMetadata.AttributeType == AttributeType.Status ) { let optionsetMetadata = attributeMetadata as ShkoOnline.PickListAttributeMetadata; if (optionsetMetadata.OptionSet?.MetadataId) { this.db.exec('DELETE FROM Metadata__Optionset WHERE OptionSetId = ?', [ optionsetMetadata.OptionSet.MetadataId, ]); this.db.exec('DELETE FROM Metadata__Optionset_Option WHERE OptionSetId = ?', [ optionsetMetadata.OptionSet.MetadataId, ]); } this.createAttribute(tableDB[0].EntityId, { AttributeOf: attributeMetadata.LogicalName, AttributeType: AttributeType.Virtual, LogicalName: virtualAttribute, SchemaName: (attributeMetadata.SchemaName || attributeMetadata.LogicalName) + 'Name', } as ShkoOnline.AttributeMetadata); if (!(attributeDB && attributeDB.length > 0)) this.db.exec('ALTER TABLE ' + safeTableName + ' ADD COLUMN [' + virtualAttribute + '] string'); if (optionsetMetadata.OptionSet) { this.OptionSetMetadataSQL.AddOptionSetMetadata({ IsCustomOptionSet: optionsetMetadata.OptionSet.IsCustomOptionSet, LogicalName: optionsetMetadata.OptionSet.Name, OptionSetId: optionsetMetadata.OptionSet.MetadataId, OptionSetType: optionsetMetadata.OptionSet.OptionSetType, }); for (let optionValue in optionsetMetadata.OptionSet.Options) { const option = optionsetMetadata.OptionSet.Options[optionValue]; const optionId = this._newId(); this.OptionSetMetadataSQL.AddOptionMetadata({ OptionId: optionId, OptionSetId: optionsetMetadata.OptionSet.MetadataId, Color: option.Color, Label: option.Label, Value: option.Value, }); } } } if (!(attributeDB && attributeDB.length > 0)) { this.db.exec( 'ALTER TABLE ' + safeTableName + ' ADD COLUMN [' + attributeMetadata.LogicalName + '] ' + getSqlTypeForAttribute(attributeMetadata.AttributeType), ); if (attributeMetadata.AttributeType === AttributeType.Lookup) { this.db.exec( 'ALTER TABLE ' + safeTableName + ' ADD COLUMN [' + attributeMetadata.LogicalName + 'type] string', ); this.db.exec( 'ALTER TABLE ' + safeTableName + ' ADD COLUMN [' + attributeMetadata.LogicalName + 'navigation] string', ); } } } /** * Initialize the in-memory database with the items passed as param * @param items Object as returned from the Dataverse OData API */ initItems(items: { '@odata.context': string; value: any[] }) { const entitySetName = items['@odata.context']?.substring(items['@odata.context'].indexOf('#') + 1); const tableMetadataDB = this.EntityMetadataSQL.SelectTableMetadataByEntitySet(entitySetName); if (!tableMetadataDB || tableMetadataDB.length === 0) { if (this._warnMissingInit) { console.warn(`Missing init for entitySet ${entitySetName}`); } return; } const tableMetadata = this.getTableMetadata(tableMetadataDB[0].LogicalName); items.value.forEach((item) => { if ( tableMetadataDB[0].LogicalName === MetadataDB.CanvasLogicalName && this.db.exec<{Records:number}[]>('SELECT Count(1) as Records FROM _canvasapp')[0].Records > 0 ) { tableMetadata?.Attributes?.forEach((attribute) => { if (attribute.LogicalName in item) { this.UpdateValue( item[attribute.LogicalName as unknown as keyof typeof item], MetadataDB.CanvasLogicalName, attribute.LogicalName, ); } }); } else { this.AddRow(tableMetadataDB[0].LogicalName, item, tableMetadata); } }); } /** * Add a new row to the table * @param entity The target table * @param item The data * @param tableMetadata The table metadata */ AddRow(entity: string, item: any, tableMetadata?: ShkoOnline.EntityMetadata) { if (!tableMetadata) { tableMetadata = this.getTableMetadata(entity); } if (!tableMetadata) { if (entity === 'savedquery') { tableMetadata = SavedQueryMetadata; this.initMetadata([tableMetadata]); } else if (entity === 'userquery') { tableMetadata = UserQueryMetadata; this.initMetadata([tableMetadata]); } else { if (this._warnMissingInit) { console.warn(`Missing init for entitySet ${entity}`); } return; } } const safeTableName = tableMetadata.LogicalName.toLowerCase().replace(/!/g, '_').replace(/@/g, '_'); const params: string[] = []; const columns: string[] = []; let newID = null; tableMetadata.Attributes?.forEach((attribute) => { const key = attribute.LogicalName; if (attribute.AttributeType === AttributeType.Uniqueidentifier) { let value = item[attribute.LogicalName]; if (!value && tableMetadata?.PrimaryIdAttribute === attribute?.LogicalName) { value = this._newId(); } if (tableMetadata?.PrimaryIdAttribute === attribute?.LogicalName) { newID = value; } params.push(value); } else if (attribute.AttributeType === AttributeType.Lookup) { const lookupNameAttribute = tableMetadata?.Attributes?.find( (attr) => attr.AttributeOf === attribute.LogicalName && attr.LogicalName !== attribute.LogicalName + 'yominame', ); if (typeof item[key] === 'object') { const lookup = item[key] as ComponentFramework.LookupValue; params.push(lookup?.name as string); columns.push('[' + lookupNameAttribute?.LogicalName + ']'); params.push(lookup?.entityType); columns.push('[' + key + 'type]'); params.push(lookup?.id); } else { params.push(item[`_${key}_value@OData.Community.Display.V1.FormattedValue`]); columns.push('[' + lookupNameAttribute?.LogicalName + ']'); params.push(item[`_${key}_value@Microsoft.Dynamics.CRM.lookuplogicalname`]); columns.push('[' + key + 'type]'); params.push(item[`_${key}_value@Microsoft.Dynamics.CRM.associatednavigationproperty`]); columns.push('[' + key + 'navigation]'); params.push(item[`_${key}_value`]); } } else if (attribute.AttributeOf && attribute.LogicalName === attribute.AttributeOf + 'name') { const parentAttribute = tableMetadata?.Attributes?.find( (attr) => attr.LogicalName === attribute.AttributeOf, ); if (parentAttribute?.AttributeType === AttributeType.Lookup) { return; } params.push(item[`${attribute.AttributeOf}@OData.Community.Display.V1.FormattedValue`]); } // `_${key}_value` in item) { // row[key] = item[`_${key}_value`] // ? ({ // entityType: item[`_${key}_value@Microsoft.Dynamics.CRM.lookuplogicalname`], // id: item[`_${key}_value`], // name: item[`_${key}_value@OData.Community.Display.V1.FormattedValue`], // } as ComponentFramework.LookupValue) // : null; // if (`_${key}_value@Microsoft.Dynamics.CRM.associatednavigationproperty` in item) // row[`_${key}_value@Microsoft.Dynamics.CRM.associatednavigationproperty`] = // item[`_${key}_value@Microsoft.Dynamics.CRM.associatednavigationproperty`]; // Temporary hack else if (key in item) { let value = attribute.AttributeType === AttributeType.DateTime && typeof item[key] === 'string' ? new Date(item[key]) : item[key]; if (value === null || value === undefined) { return; } params.push(value); } else { return; } columns.push('[' + key + ']'); }); const insertSql = `INSERT INTO ${safeTableName} (${columns.join(',')}) VALUES (${params .map((p) => '?') .join(',')})`; this.db.exec(insertSql, params); return newID; } /** * Same as init items but for Pseudo-Table #!CanvasApp * @param items */ initCanvasItems(items: any[]) { this.initItems({ '@odata.context': '#!CanvasApp', value: items, }); } /** * Get a row from the table and the relative metadata * @param entity The target table * @param id The target row * @returns */ GetRow(entity: string, id?: string) { const entityMetadata = this.getTableMetadata(entity); if (!entityMetadata) return { row: null, entityMetadata }; const safeTableName = entity.toLowerCase().replace(/!/g, '_').replace(/@/g, '_'); let selectQuery = 'SELECT ' + (entityMetadata?.Attributes ? entityMetadata?.Attributes?.map((attr) => attr.AttributeType === AttributeType.Lookup ? '[' + attr.LogicalName + '],[' + attr.LogicalName + 'type],[' + attr.LogicalName + 'navigation]' : '[' + attr.LogicalName + ']', ).join(',') : '*') + ' FROM ' + safeTableName; if (id) { selectQuery += ' WHERE [' + (entityMetadata?.PrimaryIdAttribute || 'id') + '] = ?'; } const rowDB = this.db.exec<{ [attribute: string]: any }[]>(selectQuery, [id]); if (!rowDB || rowDB.length == 0) { console.warn('could not find row'); return { row: null, entityMetadata }; } const row = rowDB[0]; entityMetadata?.Attributes?.forEach((attr) => { if (attr.AttributeType === AttributeType.Lookup) { if (row[attr.LogicalName]) { const nameAttribute = entityMetadata?.Attributes?.find((attrName) => attrName.AttributeOf === attr.LogicalName) ?.LogicalName || attr.LogicalName + 'name'; row[attr.LogicalName] = { id: row[attr.LogicalName], entityType: row[attr.LogicalName + 'type'], name: row[nameAttribute], } as ComponentFramework.LookupValue; delete row[attr.LogicalName + 'type']; delete row[nameAttribute]; } } else if (attr.AttributeType === AttributeType.Picklist) { if (typeof row[attr.LogicalName] === 'string') { row[attr.LogicalName] = JSON.parse(row[attr.LogicalName]); } } }); return { row, entityMetadata }; } /** * Delete a row from the in-memory database * @param entity The target table * @param id The target row */ RemoveRow(entity: string, id: string) { const entityMetadata = this.getTableMetadata(entity); const safeTableName = entity.toLowerCase().replace(/!/g, '_').replace(/@/g, '_'); const selectQuery = 'DELETE FROM ' + safeTableName + ' WHERE [' + (entityMetadata?.PrimaryIdAttribute || 'id') + '] = ?'; this.db.exec(selectQuery, [id]); } /** * Gets a row from a table and the associated metadata * @param entity The target table * @param id The target row * @returns */ GetRowForAPI(entity: string, id?: string) { const result = this.GetRow(entity, id); if (!result.entityMetadata) { return result; } if (result.entityMetadata && result.entityMetadata.Attributes && result.row) { result.entityMetadata.Attributes.filter((attr) => attr.AttributeType === AttributeType.Lookup).forEach( (lookupAttribute) => { const key = lookupAttribute.LogicalName; const lookupValue = result.row[key] as ComponentFramework.LookupValue; delete result.row[key]; result.row[`_${key}_value`] = lookupValue; }, ); } return result; } /** * Get a coulmn value and metadata * @param entity The target table * @param attributeName The target column * @param rowid The target row * @returns */ GetValueAndMetadata( entity: string, attributeName: string, rowid?: string, ) { const result = this.GetRow(entity, rowid); const attributeMetadata = result.entityMetadata?.Attributes?.find( (attribute) => attribute.LogicalName === attributeName, ) as TAttribute; const value = result.row?.[attributeName] as TValue; return { value, attributeMetadata }; } /** * Get all the rows for a table * @param entity The target table * @returns */ GetAllRows(entity: string) { const entityMetadata = this.getTableMetadata(entity); const safeTableName = entity.toLowerCase().replace(/!/g, '_').replace(/@/g, '_'); const rows = this.db.exec<{ [attr: string]: any }[]>('SELECT * FROM ' + safeTableName); return { rows, entityMetadata }; } /** * Update the value stored in a specific column in the in-memory database * @param value The new value * @param entity The target table * @param attribute The target column * @param row The target row */ UpdateValue(value: T, entity: string, attribute: string, rowId?: string) { const tableMetadata = this.getTableMetadata(entity); if (!tableMetadata) { return; } const safeTableName = tableMetadata.LogicalName.toLowerCase().replace(/!/g, '_').replace(/@/g, '_'); const attributeMetadata = tableMetadata.Attributes?.find((attr) => attr.LogicalName === attribute); if (!attributeMetadata) { if (this._warnMissingInit) { console.warn(`Missing init for attribute '${attribute}' of table '${entity}'`); } return; } const statements: string[] = []; const params: (string | null)[] = []; if (attributeMetadata.AttributeType === AttributeType.Lookup) { const lookupNameAttribute = tableMetadata.Attributes?.find((attr) => attr.AttributeOf === attributeMetadata.LogicalName) ?.LogicalName || attributeMetadata.LogicalName + 'name'; const lookupValue = value as ComponentFramework.LookupValue; statements.push(` SET [${attributeMetadata.LogicalName}] = ?, [${attributeMetadata.LogicalName}type] = ?, [${lookupNameAttribute}] = ? `); params.push(lookupValue && lookupValue.id ? lookupValue.id : null); params.push(lookupValue && lookupValue.id ? lookupValue.entityType : null); params.push(lookupValue && lookupValue.id ? (lookupValue.name as string) : null); } else if (attributeMetadata.AttributeType === AttributeType.Picklist) { const picklistNameAttribute = tableMetadata?.Attributes?.find((attr) => attr.AttributeOf === attributeMetadata.LogicalName) ?.LogicalName || attributeMetadata.LogicalName + 'name'; statements.push(` SET [${attributeMetadata.LogicalName}] = ?, [${picklistNameAttribute}] = ? `); if (typeof value === 'object' && value instanceof Array) { // multiselect params.push(JSON.stringify(value)); } else { params.push(value as string); const label = (attributeMetadata as ShkoOnline.PickListAttributeMetadata)?.OptionSet.Options[ value as number ]?.Label; if (!label) { console.warn(`Picklist column ${attributeMetadata?.LogicalName} is missing option ${value}}`); } params.push(label || ''); } } else { statements.push(` SET [${attributeMetadata.LogicalName}] = ? `); params.push(value as string); } if (rowId) { statements.push(` WHERE ${tableMetadata.PrimaryIdAttribute} = ? `); params.push(rowId); } this.db.exec(`UPDATE ${safeTableName} ${statements.join(' ')}`, params); } SelectUsingFetchXml(fetchXml: XMLDocument) :ComponentFramework.WebApi.Entity[] { const fetchNode = fetchXml.documentElement; const entityNode = fetchNode.firstElementChild; if (!entityNode) { throw new Error('Fetch does not contain the entity node'); } const attributesX = entityNode.getElementsByTagName('attribute'); const attributes: string[] = []; if (fetchNode.getAttribute('aggregate') === 'true') { for (let i = 0; i < attributesX.length; i++) { const attribute = attributesX[i].getAttribute('name') as string; const aggregate = attributesX[i].getAttribute('aggregate') as string; const alias = attributesX[i].getAttribute('alias') as string; attributes.push(`${aggregate}(${attribute}) as [${alias}]`); } } else { for (let i = 0; i < attributesX.length; i++) { attributes.push(attributesX[i].getAttribute('name') as string); } } return this.db.exec(`SELECT ${attributes.join(',')} FROM ${entityNode.getAttribute('name')}`); } SelectUsingOData(tableMetadata: ShkoOnline.EntityMetadata, query: ODataQuery) { const safeTableName = tableMetadata.LogicalName.toLowerCase().replace(/!/g, '_').replace(/@/g, '_'); const attributes: string[] = []; if (query.$select && tableMetadata.Attributes) { tableMetadata.Attributes.forEach((attribute) => { if ( attribute.AttributeType === AttributeType.Lookup && query.$select?.find((a) => a === `_${attribute.LogicalName}_value`) ) { attributes.push(attribute.LogicalName, attribute.LogicalName + 'name', attribute.LogicalName + 'type'); } else if ( query.$select?.find((a) => a === attribute.LogicalName) || attribute.LogicalName === tableMetadata.PrimaryIdAttribute ) { attributes.push(attribute.LogicalName); if ( attribute.AttributeType === AttributeType.Boolean || attribute.AttributeType === AttributeType.Picklist || attribute.AttributeType === AttributeType.State || attribute.AttributeType === AttributeType.Status ) { attributes.push(attribute.LogicalName + 'name'); } } }); } if(attributes.length === 0) { attributes.push('*'); } let whereClause = ''; const params: string[] = []; if (query.$filter) { whereClause = ' WHERE ' + GetWhereClauseFromFilter(query.$filter, tableMetadata, params); } let OrderByClause = ''; if(query.$orderby && query.$orderby.length > 0 && tableMetadata.Attributes) { OrderByClause = ' ORDER BY ' + query.$orderby.map(element => { return (tableMetadata.Attributes!.find((x) => x.AttributeType === AttributeType.Lookup && element.column === `_${x.LogicalName}_value`)?.LogicalName ?? element.column) + " " + (element.asc ? 'ASC' : 'DESC'); }).join(','); } return this.db.exec(`SELECT ${attributes.join(',')} FROM ${safeTableName} ${whereClause} ${OrderByClause}`, params); } } function GetWhereClauseFromFilter(filter: FilterOperator, tableMetadata: ShkoOnline.EntityMetadata, params: string[]):string { switch (filter.operator) { case 'and': case 'or': return `${GetWhereClauseFromFilter(filter.left, tableMetadata, params)} ${filter.operator.toUpperCase()} ${GetWhereClauseFromFilter(filter.right, tableMetadata, params)}`; case 'not': return `NOT (${GetWhereClauseFromFilter(filter.right, tableMetadata, params)})`; case 'eq': case 'ne': case 'gt': case 'ge': case 'lt': case 'le': if('isColumnOperation' in filter && filter.isColumnOperation) { return `[${filter.left}] ${filter.operator} [${filter.right}]`; }else if('isBooleanOperation' in filter && filter.isBooleanOperation) { return `[${filter.left}] ${filter.operator} ${filter.right}`; }else if('isNullOperation' in filter && filter.isNullOperation) { return `[${filter.left}] IS ${filter.operator === 'eq' ? '' : 'NOT '}NULL`; }else{ params.push(filter.right as string); return `[${filter.left}] ${ODataOperatorToSqlOperator(filter.operator)} ?`; } case 'contains': params.push(`%${filter.right}%`); return `[${filter.left}] LIKE ?`; case 'startswith': params.push(`${filter.right}%`); return `[${filter.left}] LIKE ?`; case 'endswith': params.push(`%${filter.right}`); return `[${filter.left}] LIKE ?`; default: console.error(`Operator ${(filter as any).operator as string} not supported`); return ''; } function ODataOperatorToSqlOperator(operator: string) { switch(operator) { case 'eq': return '='; case 'ne': return '<>'; case 'gt': return '>'; case 'ge': return '>='; case 'lt': return '<'; case 'le': return '<='; default: return operator; } } }