/**
 * Manages the mapping between TypeScript and EC Classes/Properties to transform back and forth
 * between TypeScript objects (strongly typed) and raw JSON instances. The raw JSON instances can
 * conform to one or more "application" ECSchemas.
 *
 * The mappings are setup on the TypeScript classes using class decorators:  ECJsonTypeMap . classToJson * and TypeScript properties using property decorators:  ECJsonTypeMap . propertyToJson .
 *
 * The following utility methods can then be used to convert between TypeScript and JSON:
 *  ECJsonTypeMap . fromJson is used to convert a JSON instance into an instance of type T.
 *  ECJsonTypeMap . toJson is used to convert an instance of some type T to JSON.
 * The caller passes the application as an argument to these methods to identify the schema of the
 * raw JSON that's being generated or consumed.
 *
 * #### example
 * The BriefcaseManager API requires that a briefcase be fetched from the server (based on the "wsg" ECSchema-s),
 * kept in memory as a strongly typed TypeScript object, and then saved locally in a ECDb cache (based on
 * the ECSchema in the ECDb). The JSON would need to be transformed between WSG, ECDb and in-memory TypeScript
 * objects. Listed below are:
 * 1. the EC-JSON required/sent by WSG
 * 2. the EC-JSON required/sent by ECDb
 * 3. the TypeScript instance
 * 4. the corresponding TypeScript class definition with the class and property decorators to make the required transformations.
 *
 * 1. the EC-JSON required/sent by WSG
 *     const wsgJsonForBriefcase: any = {
 *       instanceId: "5",
 *       schemaName: "iModelScope",
 *       className: "Briefcase",
 *       eTag: "87fgQrN6y3mHD6ciCsPxhCdxCxU=",
 *       properties: {
 *         FileName: "MyTestModel.bim",
 *         FileDescription: null,
 *         FileSize: "1232896",
 *         FileId: "0056da15-2009-4862-b82a-c031cb3902d8",
 *         BriefcaseId: 5,
 *         UserOwned: "efefac5b-9a57-488b-aed2-df27bffe6d78",
 *         AcquiredDate: "2017-07-04T18:08:15.77",
 *         IsReadOnly: false,
 *         },
 *       relationshipInstances: [{
 *         className: "FileAccessKey",
 *         schemaName: "iModelScope",
 *         direction: "forward"
 *         relatedInstance: {
 *           className: "AccessKey",
 *           schemaName: "iModelScope",
 *           properties: {
 *           DownloadUrl: "https://imodelhubqasa01.blob.core.windows.net/imodelhub-5018f11f-...",
 *           },
 *         },
 *       },
 *       ],
 *     };
 *
 * 2. the EC-JSON required/sent by ECDb
 *     const ecdbJsonForBriefcase: any = {
 *       id: "1",
 *       className: "ServiceStore.LocalBriefcase",
 *       briefcaseId: 5,
 *       userId: "efefac5b-9a57-488b-aed2-df27bffe6d78",
 *       isReadOnly: false,
 *       accessMode: 0,
 *       localPathname: "d:/SomePathHere/MyTestModel.bim",
 *       lastAccessedAt: "2017-07-04T18:08:15.77",
 *     };
 *
 * 3. the TypeScript instance
 *     const briefcase: Briefcase = {
 *       id: "5",
 *       federationGuid: "5",
 *       eTag: "87fgQrN6y3mHD6ciCsPxhCdxCxU=",
 *       fileName: "MyTestModel.bim",
 *       fileDescription: null,
 *       fileSize: "1232896",
 *       fileId: "0056da15-2009-4862-b82a-c031cb3902d8",
 *       briefcaseId: 5,
 *       userId: "efefac5b-9a57-488b-aed2-df27bffe6d78",
 *       acquiredDate: "2017-07-04T18:08:15.77",
 *       isReadOnly: false,
 *       downloadUrl: "https://imodelhubqasa01.blob.core.windows.net/i...",
 *       accessMode: 0,
 *       localPathname: "%TEMP%iModeljs/imodelName/",
 *       lastAccessedAt: "2017-07-04T18:08:15.77",
 *     };
 *
 * 4. the corresponding TypeScript class definition with the class and property decorators
 *
 *     // Base class for all typed instances mapped to ECInstance-s in an ECDb
 *     export abstract class ECInstance
 *     {
 *        ECJsonTypeMap ECJsonTypeMap ECJsonTypeMap ECJsonTypeMap ECJsonTypeMap ECJsonTypeMap ECJsonTypeMap ECJsonTypeMap ECJsonTypeMap ECJsonTypeMap ECJsonTypeMap ECJsonTypeMap ECJsonTypeMap ECJsonTypeMap ECJsonTypeMap ECJsonTypeMap ECJsonTypeMap ECJsonTypeMap ECJsonTypeMap ECJsonTypeMap ECJsonTypeMap import { assert , Logger } from "@itwin/core-bentley" ; import { WsgClientLoggerCategory } from "./WsgLoggerCategory" ; internal export type ConstructorType = new ( ) => any ; const loggerCategory = WsgClientLoggerCategory . ECJson ; const className = "className" ; internal export interface ClassKeyMapInfo { /** The key of the JSON property that stores the schema name - e.g., set to"schemaName" in the case of JSON consumed/supplied by WSG */ schemaPropertyName ? : string ; /** The key of the JSON property that stores the class name - e.g., set to "className" in the case of JSON consumed/supplied by WSG. */ classPropertyName ? : string ; /** The key of the JSON property that stores the "ECClassKey" - e.g., set to "className" in the case of JSON consumed/supplied by the ECDb API */ classKeyPropertyName ? : string ; } class PropertyEntry { constructor ( public readonly typedPropertyName , public propertyAccessString ) { } } type PropertiesByAccessString = Map < string , PropertyEntry > ; class ApplicationEntry { public readonly propertiesByAccessString : PropertiesByAccessString = new Map < string , PropertyEntry > ( ) ; public classKey : string ; public classKeyMapInfo : ClassKeyMapInfo ; /** Constructor */ constructor ( public applicationKey ) { } private getPropertyByAccessString ( propertyAccessString ) { return this . propertiesByAccessString . get ( propertyAccessString ) ; } /** Adds a new entry for a mapped property */ public addProperty ( typedPropertyName , propertyAccessString ) { let propertyEntry = this . getPropertyByAccessString ( propertyAccessString ) ; if ( propertyEntry ) { const err = `The ECProperty ${ propertyAccessString } has already been mapped to another TypeScript property ${ propertyEntry . typedPropertyName }` ; throw new Error ( err ) ; } propertyEntry = new PropertyEntry ( typedPropertyName , propertyAccessString ) ; this . propertiesByAccessString . set ( propertyAccessString , propertyEntry ) ; } } type ApplicationsByKey = Map < string , ApplicationEntry > ; /** Entry in the registry to capture the mapping between a single EC and TypeScript class */ class ClassEntry { public readonly typedConstructor : ConstructorType ; public readonly baseClassEntries : ClassEntry [ ] = new Array < ClassEntry > ( ) ; public readonly applicationsByKey : ApplicationsByKey = new Map < string , ApplicationEntry > ( ) ; /** Constructor */ constructor ( typedConstructor ) { this . typedConstructor = typedConstructor ; } /** Gets the application entry by the key  */ public getApplicationByKey ( applicationKey ) { return this . applicationsByKey . get ( applicationKey ) ; } /** Adds a new entry for a mapped application within a class  */ public addApplication ( applicationKey ) { let applicationEntry = this . getApplicationByKey ( applicationKey ) ; if ( applicationEntry ) throw new Error ( `Internal error: Cannot add the same application twice` ) ; applicationEntry = new ApplicationEntry ( applicationKey ) ; this . applicationsByKey . set ( applicationKey , applicationEntry ) ; return applicationEntry ; } } type ClassesByTypedName = Map < ConstructorType , ClassEntry > ; internal export class ECJsonTypeMap { private static _classesByTypedName : ClassesByTypedName = new Map < ConstructorType , ClassEntry > ( ) ; private static readonly _negetadBracketRegex : RegExp = /[^\[\]]+/g ; private static readonly _relationshipDescriptorPairsInParanthesis : RegExp = /\(([a-zA-Z]*:[a-zA-Z]*,*)*\)/g ; /** Gets an existing entry for a mapped class from the name of the TypeScript class */ private static getClassByType ( typedConstructor ) { return ECJsonTypeMap . _classesByTypedName . get ( typedConstructor ) ; } /** Recursively gathers all class entries for base classes starting with the specified class */ private static gatherBaseClassEntries ( baseClassEntries , classEntry ) { const baseTypedConstructor = Object . getPrototypeOf ( classEntry . typedConstructor . prototype ) . constructor ; const baseClassEntry = ECJsonTypeMap . getClassByType ( baseTypedConstructor ) ; if ( baseClassEntry ) { baseClassEntries . push ( baseClassEntry ) ; this . gatherBaseClassEntries ( baseClassEntries , baseClassEntry ) ; } } private static addClassPlaceholder ( typedConstructor ) { const classEntry = new ClassEntry ( typedConstructor ) ; ECJsonTypeMap . _classesByTypedName . set ( typedConstructor , classEntry ) ; ECJsonTypeMap . gatherBaseClassEntries ( classEntry . baseClassEntries , classEntry ) ; return classEntry ; } /** Adds a new entry for a mapped class */ private static addClass ( typedConstructor , applicationKey , classKey , classKeyMapInfo ) { if ( ! ( classKeyMapInfo . classKeyPropertyName || ( classKeyMapInfo . schemaPropertyName && classKeyMapInfo . classPropertyName ) ) ) throw new Error ( "Either classKeyPropertyName or schemaPropertyName+classPropertyName have to be supplied to identify how the class information is persisted in JSON" ) ; let classEntry = ECJsonTypeMap . getClassByType ( typedConstructor ) ; if ( ! classEntry ) { classEntry = this . addClassPlaceholder ( typedConstructor ) ; } let applicationEntry = classEntry . getApplicationByKey ( applicationKey ) ; if ( ! applicationEntry ) { applicationEntry = classEntry . addApplication ( applicationKey ) ; } else if ( applicationEntry . classKey === classKey ) { const err = `Duplicate classKeys ${ classKey } found on TypeScript class ${ typedConstructor . name } for application ${ applicationKey }` ; throw new Error ( err ) ; } applicationEntry . classKey = classKey ; applicationEntry . classKeyMapInfo = classKeyMapInfo ; return classEntry ; } /** Adds a new entry for a mapped property */ private static addProperty ( typedPropertyName , typedConstructor , applicationKey , propertyAccessString ) { let classEntry = ECJsonTypeMap . getClassByType ( typedConstructor ) ; if ( ! classEntry ) classEntry = ECJsonTypeMap . addClassPlaceholder ( typedConstructor ) ; let applicationEntry = classEntry . getApplicationByKey ( applicationKey ) ; if ( ! applicationEntry ) applicationEntry = classEntry . addApplication ( applicationKey ) ; applicationEntry . addProperty ( typedPropertyName , propertyAccessString ) ; } private static getRelationshipDesciptors ( relationshipDesciptorsString ) { const relationshipDescriptorsArray = relationshipDesciptorsString . split ( "," ) ; const relationshipDescriptorMap = new Map < string , string > ( ) ; for ( const relationshipDescriptor of relationshipDescriptorsArray ) { const parsedRelationshipDescriptor = relationshipDescriptor . split ( ":" ) ; if ( parsedRelationshipDescriptor . length !== 2 ) { throw new Error ( `Invalid relationship descriptor provided: ${ relationshipDescriptor }. Relationship descriptors have to be suplied as 'accessor:value' pairs.` ) ; } relationshipDescriptorMap . set ( parsedRelationshipDescriptor [ 0 ] , parsedRelationshipDescriptor [ 1 ] ) ; } return relationshipDescriptorMap ; } /** Create a typed instance from an untyped JSON ECInstance  */ public static fromJson < T extends ECInstance > ( typedConstructor , applicationKey , ecJsonInstance ) { const mappedClassEntry = ECJsonTypeMap . getClassByType ( typedConstructor ) ; if ( ! mappedClassEntry ) { Logger . logError ( loggerCategory , `Type ${ typedConstructor . name } is not mapped to an ECClass. Supply the appropriate class decorator` ) ; return undefined ; } const lowCaseApplicationKey = applicationKey . toLowerCase ( ) ; const mappedApplicationEntry = mappedClassEntry . getApplicationByKey ( lowCaseApplicationKey ) ; if ( ! mappedApplicationEntry ) { Logger . logError ( loggerCategory , `Type ${ typedConstructor . name } is not mapped for the supplied application.` ) ; return undefined ; } let ecJsonClassKey ; if ( mappedApplicationEntry . classKeyMapInfo . classKeyPropertyName ) ecJsonClassKey = ecJsonInstance [ mappedApplicationEntry . classKeyMapInfo . classKeyPropertyName ] ; else if ( mappedApplicationEntry . classKeyMapInfo . schemaPropertyName && mappedApplicationEntry . classKeyMapInfo . classPropertyName ) ecJsonClassKey = `${ ecJsonInstance [ mappedApplicationEntry . classKeyMapInfo . schemaPropertyName ] }.${ ecJsonInstance [ mappedApplicationEntry . classKeyMapInfo . classPropertyName ] }` ; else { assert ( false , "Unexpected classKeyMapInfo" ) ; return undefined ; } if ( mappedApplicationEntry . classKey !== ecJsonClassKey ) { Logger . logError ( loggerCategory , `The ClassKey ${ mappedApplicationEntry . classKey } was specified to map with ${ typedConstructor . name }, but does not match that specified in the JSON: ${ ecJsonClassKey } ` ) ; return undefined ; } let classEntries = Array < ClassEntry > ( mappedClassEntry ) ; classEntries = classEntries . concat ( mappedClassEntry . baseClassEntries ) ; const typedInstance = new typedConstructor ( ) ; classEntries . forEach ( ( classEntry ) => { const applicationEntry = classEntry . getApplicationByKey ( lowCaseApplicationKey ) ; if ( ! applicationEntry ) return ; applicationEntry . propertiesByAccessString . forEach ( ( propertyEntry , propertyAccessString ) => { let ecValue = ecJsonInstance ; const ecNameParts = propertyAccessString . split ( "." ) ; for ( let i = 0 ; i < ecNameParts . length ; i ++ ) { const ecNameSubParts = ecNameParts [ i ] . match ( this . _negetadBracketRegex ) ; if ( ! ecNameSubParts || ecNameSubParts . length === 0 || ecNameSubParts . length > 3 ) return ; if ( ecNameSubParts . length === 3 ) { if ( ecNameSubParts [ 2 ] . startsWith ( "(" ) && ecNameSubParts [ 2 ] . endsWith ( ")" ) ) ecNameSubParts . splice ( 2 , 1 ) ; } const subAccessString = ecNameSubParts [ 0 ] ; ecValue = ecValue [ subAccessString ] ; if ( typeof ecValue === "undefined" ) return ; if ( ecNameSubParts . length === 2 && subAccessString === "relationshipInstances" && i < ecNameParts . length - 1 ) { const nextEcNameSubParts = ecNameParts [ i + 1 ] . match ( this . _negetadBracketRegex ) ; if ( ! nextEcNameSubParts || nextEcNameSubParts . length !== 2 ) return ; const expectedRelationshipInstanceClass = ecNameSubParts [ 1 ] ; const relatedInstanceAccessString = nextEcNameSubParts [ 0 ] ; const expectedRelatedInstanceClass = nextEcNameSubParts [ 1 ] ; let arrayIndex = 0 ; let arrayValue ; let actualRelatedInstanceClass ; while ( expectedRelatedInstanceClass !== actualRelatedInstanceClass ) { arrayValue = ecValue [ arrayIndex ++ ] ; if ( typeof arrayValue === "undefined" ) return ; if ( expectedRelationshipInstanceClass === arrayValue [ className ] ) { const relatedInstance = arrayValue [ relatedInstanceAccessString ] ; if ( relatedInstance !== "undefined" ) actualRelatedInstanceClass = relatedInstance [ className ] ; } } ecValue = arrayValue [ relatedInstanceAccessString ] ; i ++ ; } } typedInstance [ propertyEntry . typedPropertyName as keyof T ] = ecValue ; } ) ; } ) ; return typedInstance ; } /** Create an untyped instance from a typed instance */ public static toJson < T extends ECInstance > ( applicationKey , typedInstance ) { const lowCaseApplicationKey = applicationKey . toLowerCase ( ) ; const typedConstructor = Object . getPrototypeOf ( typedInstance ) . constructor ; const mappedClassEntry = ECJsonTypeMap . getClassByType ( typedConstructor ) ; if ( ! mappedClassEntry ) return undefined ; const mappedApplicationEntry = mappedClassEntry . getApplicationByKey ( lowCaseApplicationKey ) ; if ( ! mappedApplicationEntry ) return undefined ; let schemaPropertyAccessor ; let schemaName ; const untypedInstance = { } ; if ( mappedApplicationEntry . classKeyMapInfo . classKeyPropertyName ) { untypedInstance [ mappedApplicationEntry . classKeyMapInfo . classKeyPropertyName ] = mappedApplicationEntry . classKey ; } else if ( mappedApplicationEntry . classKeyMapInfo . schemaPropertyName && mappedApplicationEntry . classKeyMapInfo . classPropertyName ) { const classKeyParts = mappedApplicationEntry . classKey . split ( "." , 2 ) ; untypedInstance [ mappedApplicationEntry . classKeyMapInfo . schemaPropertyName ] = classKeyParts [ 0 ] ; untypedInstance [ mappedApplicationEntry . classKeyMapInfo . classPropertyName ] = classKeyParts [ 1 ] ; schemaPropertyAccessor = mappedApplicationEntry . classKeyMapInfo . schemaPropertyName ; schemaName = classKeyParts [ 0 ] ; } else { assert ( false , "Unexpected classKeyMapInfo" ) ; return undefined ; } let classEntries = Array < ClassEntry > ( mappedClassEntry ) ; classEntries = classEntries . concat ( mappedClassEntry . baseClassEntries ) ; classEntries . forEach ( ( classEntry ) => { const applicationEntry = classEntry . getApplicationByKey ( lowCaseApplicationKey ) ; if ( ! applicationEntry ) return ; const relationshipClassToDescriptor = new Map < string , Map < string , string > > ( ) ; applicationEntry . propertiesByAccessString . forEach ( ( propertyEntry , propertyAccessString ) => { const typedValue = typedInstance [ propertyEntry . typedPropertyName ] ; if ( typeof typedValue === "undefined" ) return ; const ecNameParts = propertyAccessString . split ( "." ) ; let untypedInstanceCursor = untypedInstance ; ecNameParts . forEach ( ( ecNamePart , index ) => { const ecNameSubParts = ecNamePart . match ( this . _negetadBracketRegex ) ; if ( ! ecNameSubParts || ecNameSubParts . length === 0 || ecNameSubParts . length > 3 ) return ; const accessString = ecNameSubParts [ 0 ] ; const isLastPart = index >= ecNameParts . length - 1 ; if ( ecNameSubParts . length === 1 ) { if ( undefined === untypedInstanceCursor [ accessString ] ) { let value = { } ; if ( isLastPart ) { value = typedValue ; } untypedInstanceCursor [ accessString ] = value ; } untypedInstanceCursor = untypedInstanceCursor [ accessString ] ; return ; } const expectedclassName = ecNameSubParts [ 1 ] ; if ( ecNameSubParts . length > 2 ) { const relationshipDescriptor = ecNameSubParts [ 2 ] . match ( this . _relationshipDescriptorPairsInParanthesis ) ; if ( relationshipDescriptor && relationshipDescriptor . length === 1 ) { const parsedRelationshipDescriptors = this . getRelationshipDesciptors ( relationshipDescriptor [ 0 ] . slice ( 1 , - 1 ) ) ; const existingRelationshipDescriptors = relationshipClassToDescriptor . get ( expectedclassName ) ; if ( existingRelationshipDescriptors === undefined ) { relationshipClassToDescriptor . set ( expectedclassName , parsedRelationshipDescriptors ) ; } else { parsedRelationshipDescriptors . forEach ( ( parsedValue , parsedKey ) => { const existingDescriptorValue = existingRelationshipDescriptors . get ( parsedKey ) ; if ( undefined === existingDescriptorValue ) { existingRelationshipDescriptors . set ( parsedKey , parsedValue ) ; } else if ( existingDescriptorValue !== parsedValue ) { const err = `Relationship for class '${ expectedclassName }' cannot contain same descriptor '${ parsedKey }' with different values: existing - '${ existingDescriptorValue }', new - '${ parsedValue }'.` ; throw new Error ( err ) ; } } ) ; } } } if ( accessString === "relationshipInstances" ) { if ( ! untypedInstanceCursor [ accessString ] ) untypedInstanceCursor [ accessString ] = [ ] ; const hasNextEcPart = /\[.*\]/ . test ( ecNameParts [ index + 1 ] ) ; const nextEcNameSubParts = ecNameParts [ index + 1 ] . match ( this . _negetadBracketRegex ) ; if ( ! nextEcNameSubParts || nextEcNameSubParts . length === 0 || nextEcNameSubParts . length > 2 ) return ; const relatedInstanceAccessString = hasNextEcPart ? nextEcNameSubParts [ 0 ] : undefined ; const expectedRelatedInstanceClass = hasNextEcPart ? nextEcNameSubParts [ 1 ] : undefined ; let relationshipCount = 0 ; while ( untypedInstanceCursor [ accessString ] [ relationshipCount ] && ( untypedInstanceCursor [ accessString ] [ relationshipCount ] [ className ] !== expectedclassName || ( hasNextEcPart && untypedInstanceCursor [ accessString ] [ relationshipCount ] [ relatedInstanceAccessString ] && untypedInstanceCursor [ accessString ] [ relationshipCount ] [ relatedInstanceAccessString ] [ className ] !== expectedRelatedInstanceClass ) ) ) { relationshipCount ++ ; } if ( ! untypedInstanceCursor [ accessString ] [ relationshipCount ] ) { let value = { } ; if ( isLastPart ) { value = typedValue ; } untypedInstanceCursor [ accessString ] [ relationshipCount ] = value ; } untypedInstanceCursor = untypedInstanceCursor [ accessString ] [ relationshipCount ] ; const relationshipDescriptors = relationshipClassToDescriptor . get ( expectedclassName ) ; if ( undefined !== relationshipDescriptors ) { relationshipDescriptors . forEach ( ( descriptorValue , descriptorAccessor ) => { untypedInstanceCursor [ descriptorAccessor ] = descriptorValue ; } ) ; } } else { if ( accessString !== "relatedInstance" || ! untypedInstanceCursor [ accessString ] || ( accessString === "relatedInstance" && untypedInstanceCursor [ accessString ] [ className ] !== expectedclassName ) ) { let value = { } ; if ( isLastPart ) { value = typedValue ; } untypedInstanceCursor [ accessString ] = value ; } untypedInstanceCursor = untypedInstanceCursor [ accessString ] ; } if ( undefined === untypedInstanceCursor [ className ] ) untypedInstanceCursor [ className ] = expectedclassName ; if ( undefined !== schemaPropertyAccessor && undefined === untypedInstanceCursor [ schemaPropertyAccessor ] ) untypedInstanceCursor [ schemaPropertyAccessor ] = schemaName ; } ) ; } ) ; } ) ; return untypedInstance ; } param applicationKey param classKey param classKeyMapInfo public static classToJson ( applicationKey , classKey , classKeyMapInfo ) { return ( typedConstructor ) : void => { ECJsonTypeMap . addClass ( typedConstructor , applicationKey . toLowerCase ( ) , classKey , classKeyMapInfo ) ; } ; } param applicationKey param propertyAccessString public static propertyToJson ( applicationKey , propertyAccessString ) { return ( object , propertyKey ) : void => { ECJsonTypeMap . addProperty ( propertyKey , object . constructor as ConstructorType , applicationKey . toLowerCase ( ) , propertyAccessString ) ; } ; } } internal export abstract class ECInstance { @ ECJsonTypeMap . propertyToJson ( "ecdb" , "id" ) public ecId : string ; [ index ] : any ; } internal export type ChangeState = "new" | "modified" | "deleted" | "existing" ; internal export abstract class WsgInstance extends ECInstance { @ ECJsonTypeMap . propertyToJson ( "wsg" , "instanceId" ) @ ECJsonTypeMap . propertyToJson ( "ecdb" , "wsgId" ) public wsgId : string ; @ ECJsonTypeMap . propertyToJson ( "wsg" , "eTag" ) @ ECJsonTypeMap . propertyToJson ( "ecdb" , "eTag" ) public eTag ? : string ; @ ECJsonTypeMap . propertyToJson ( "wsg" , "changeState" ) public changeState ? : ChangeState ; }