InputParser.ts 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434
  1. import { AddClassSchema, Property } from '../../types/extrinsics/AddClassSchema'
  2. import { createType } from '@joystream/types'
  3. import {
  4. InputEntityValuesMap,
  5. ClassId,
  6. OperationType,
  7. ParametrizedPropertyValue,
  8. PropertyId,
  9. PropertyType,
  10. EntityId,
  11. Entity,
  12. ParametrizedClassPropertyValue,
  13. InputPropertyValue,
  14. } from '@joystream/types/content-directory'
  15. import { blake2AsHex } from '@polkadot/util-crypto'
  16. import { isSingle, isReference } from './propertyType'
  17. import { ApiPromise } from '@polkadot/api'
  18. import { JoyBTreeSet } from '@joystream/types/common'
  19. import { CreateClass } from '../../types/extrinsics/CreateClass'
  20. import { EntityBatch } from '../../types/EntityBatch'
  21. import { getInputs } from './inputs'
  22. type SimpleEntityValue = string | boolean | number | string[] | boolean[] | number[] | undefined
  23. // Input without "new" or "extising" keywords
  24. type SimpleEntityInput = { [K: string]: SimpleEntityValue }
  25. export class InputParser {
  26. private api: ApiPromise
  27. private classInputs: CreateClass[]
  28. private schemaInputs: AddClassSchema[]
  29. private batchInputs: EntityBatch[]
  30. private createEntityOperations: OperationType[] = []
  31. private addSchemaToEntityOprations: OperationType[] = []
  32. private updateEntityPropertyValuesOperations: OperationType[] = []
  33. private entityIndexByUniqueQueryMap = new Map<string, number>()
  34. private entityIdByUniqueQueryMap = new Map<string, number>()
  35. private entityByUniqueQueryCurrentIndex = 0
  36. private classIdByNameMap = new Map<string, number>()
  37. static createWithInitialInputs(api: ApiPromise): InputParser {
  38. return new InputParser(
  39. api,
  40. getInputs<CreateClass>('classes').map(({ data }) => data),
  41. getInputs<AddClassSchema>('schemas').map(({ data }) => data),
  42. getInputs<EntityBatch>('entityBatches').map(({ data }) => data)
  43. )
  44. }
  45. static createWithKnownSchemas(api: ApiPromise, entityBatches?: EntityBatch[]): InputParser {
  46. return new InputParser(
  47. api,
  48. [],
  49. getInputs<AddClassSchema>('schemas').map(({ data }) => data),
  50. entityBatches
  51. )
  52. }
  53. constructor(
  54. api: ApiPromise,
  55. classInputs?: CreateClass[],
  56. schemaInputs?: AddClassSchema[],
  57. batchInputs?: EntityBatch[]
  58. ) {
  59. this.api = api
  60. this.classInputs = classInputs || []
  61. this.schemaInputs = schemaInputs || []
  62. this.batchInputs = batchInputs || []
  63. }
  64. private async loadClassMap() {
  65. this.classIdByNameMap = new Map<string, number>()
  66. const classEntries = await this.api.query.contentDirectory.classById.entries()
  67. classEntries.forEach(([key, aClass]) => {
  68. this.classIdByNameMap.set(aClass.name.toString(), (key.args[0] as ClassId).toNumber())
  69. })
  70. }
  71. private async loadEntityIdByUniqueQueryMap() {
  72. this.entityIdByUniqueQueryMap = new Map<string, number>()
  73. // Get entity entries
  74. const entityEntries: [EntityId, Entity][] = (
  75. await this.api.query.contentDirectory.entityById.entries()
  76. ).map(([storageKey, entity]) => [storageKey.args[0] as EntityId, entity])
  77. // Since we use classMap directly we need to make sure it's loaded first
  78. if (!this.classIdByNameMap.size) {
  79. await this.loadClassMap()
  80. }
  81. entityEntries.forEach(([entityId, entity]) => {
  82. const classId = entity.class_id.toNumber()
  83. const className = Array.from(this.classIdByNameMap.entries()).find(([, id]) => id === classId)?.[0]
  84. if (!className) {
  85. // Class not found - skip
  86. return
  87. }
  88. let schema: AddClassSchema
  89. try {
  90. schema = this.schemaByClassName(className)
  91. } catch (e) {
  92. // Input schema not found - skip
  93. return
  94. }
  95. const valuesEntries = Array.from(entity.getField('values').entries())
  96. schema.newProperties.forEach(({ name, unique }, index) => {
  97. if (!unique) {
  98. return // Skip non-unique properties
  99. }
  100. const storedValue = valuesEntries.find(([propertyId]) => propertyId.toNumber() === index)?.[1]
  101. if (
  102. storedValue === undefined ||
  103. // If unique value is Bool, it's almost definitely empty, so we skip it
  104. (storedValue.isOfType('Single') && storedValue.asType('Single').isOfType('Bool'))
  105. ) {
  106. // Skip empty values (not all unique properties are required)
  107. return
  108. }
  109. const simpleValue = storedValue.getValue().toJSON()
  110. const hash = this.getUniqueQueryHash({ [name]: simpleValue }, schema.className)
  111. this.entityIdByUniqueQueryMap.set(hash, entityId.toNumber())
  112. })
  113. })
  114. }
  115. private schemaByClassName(className: string) {
  116. const foundSchema = this.schemaInputs.find((data) => data.className === className)
  117. if (!foundSchema) {
  118. throw new Error(`Schema not found by class name: ${className}`)
  119. }
  120. return foundSchema
  121. }
  122. private getUniqueQueryHash(uniquePropVal: Record<string, any>, className: string) {
  123. return blake2AsHex(JSON.stringify([className, uniquePropVal]))
  124. }
  125. private findEntityIndexByUniqueQuery(uniquePropVal: Record<string, any>, className: string) {
  126. const hash = this.getUniqueQueryHash(uniquePropVal, className)
  127. const foundIndex = this.entityIndexByUniqueQueryMap.get(hash)
  128. if (foundIndex === undefined) {
  129. throw new Error(
  130. `findEntityIndexByUniqueQuery failed for class ${className} and query: ${JSON.stringify(uniquePropVal)}`
  131. )
  132. }
  133. return foundIndex
  134. }
  135. // Seatch for entity by { [uniquePropName]: [uniquePropVal] } on chain
  136. async findEntityIdByUniqueQuery(uniquePropVal: Record<string, any>, className: string): Promise<number> {
  137. const hash = this.getUniqueQueryHash(uniquePropVal, className)
  138. let foundId = this.entityIdByUniqueQueryMap.get(hash)
  139. if (foundId === undefined) {
  140. // Try to re-load the map and find again
  141. await this.loadEntityIdByUniqueQueryMap()
  142. foundId = this.entityIdByUniqueQueryMap.get(hash)
  143. if (foundId === undefined) {
  144. // If still not found - throw
  145. throw new Error(
  146. `findEntityIdByUniqueQuery failed for class ${className} and query: ${JSON.stringify(uniquePropVal)}`
  147. )
  148. }
  149. }
  150. return foundId
  151. }
  152. async getClassIdByName(className: string): Promise<number> {
  153. let classId = this.classIdByNameMap.get(className)
  154. if (classId === undefined) {
  155. // Try to re-load the map
  156. await this.loadClassMap()
  157. classId = this.classIdByNameMap.get(className)
  158. if (classId === undefined) {
  159. // If still not found - throw
  160. throw new Error(`Could not find class id by name: "${className}"!`)
  161. }
  162. }
  163. return classId
  164. }
  165. private async parsePropertyType(propertyType: Property['property_type']): Promise<PropertyType> {
  166. if (isSingle(propertyType) && isReference(propertyType.Single)) {
  167. const { className, sameOwner } = propertyType.Single.Reference
  168. const classId = await this.getClassIdByName(className)
  169. return createType('PropertyType', { Single: { Reference: [classId, sameOwner] } })
  170. }
  171. // Types other than reference are fully compatible
  172. return createType('PropertyType', propertyType)
  173. }
  174. private includeEntityInputInUniqueQueryMap(entityInput: Record<string, any>, schema: AddClassSchema) {
  175. Object.entries(entityInput)
  176. .filter(([, pValue]) => pValue !== undefined)
  177. .forEach(([propertyName, propertyValue]) => {
  178. const schemaPropertyType = schema.newProperties.find((p) => p.name === propertyName)!.property_type
  179. // Handle entities "nested" via "new"
  180. if (isSingle(schemaPropertyType) && isReference(schemaPropertyType.Single)) {
  181. if (Object.keys(propertyValue).includes('new')) {
  182. const refEntitySchema = this.schemaByClassName(schemaPropertyType.Single.Reference.className)
  183. this.includeEntityInputInUniqueQueryMap(propertyValue.new, refEntitySchema)
  184. }
  185. }
  186. })
  187. // Add entries to entityIndexByUniqueQueryMap
  188. schema.newProperties
  189. .filter((p) => p.unique)
  190. .forEach(({ name }) => {
  191. if (entityInput[name] === undefined) {
  192. // Skip empty values (not all unique properties are required)
  193. return
  194. }
  195. const hash = this.getUniqueQueryHash({ [name]: entityInput[name] }, schema.className)
  196. this.entityIndexByUniqueQueryMap.set(hash, this.entityByUniqueQueryCurrentIndex)
  197. })
  198. ++this.entityByUniqueQueryCurrentIndex
  199. }
  200. private async createParametrizedPropertyValues(
  201. entityInput: Record<string, any>,
  202. schema: AddClassSchema,
  203. customHandler?: (property: Property, value: any) => Promise<ParametrizedPropertyValue | undefined>
  204. ): Promise<ParametrizedClassPropertyValue[]> {
  205. const filteredInput = Object.entries(entityInput).filter(([, pValue]) => pValue !== undefined)
  206. const parametrizedClassPropValues: ParametrizedClassPropertyValue[] = []
  207. for (const [propertyName, propertyValue] of filteredInput) {
  208. const schemaPropertyIndex = schema.newProperties.findIndex((p) => p.name === propertyName)
  209. const schemaProperty = schema.newProperties[schemaPropertyIndex]
  210. let value = customHandler && (await customHandler(schemaProperty, propertyValue))
  211. if (value === undefined) {
  212. value = createType('ParametrizedPropertyValue', {
  213. InputPropertyValue: (await this.parsePropertyType(schemaProperty.property_type)).toInputPropertyValue(
  214. propertyValue
  215. ),
  216. })
  217. }
  218. parametrizedClassPropValues.push(
  219. createType('ParametrizedClassPropertyValue', {
  220. in_class_index: schemaPropertyIndex,
  221. value,
  222. })
  223. )
  224. }
  225. return parametrizedClassPropValues
  226. }
  227. private async existingEntityQueryToParametrizedPropertyValue(className: string, uniquePropVal: Record<string, any>) {
  228. try {
  229. // First - try to find in existing batches
  230. const entityIndex = this.findEntityIndexByUniqueQuery(uniquePropVal, className)
  231. return createType('ParametrizedPropertyValue', { InternalEntityJustAdded: entityIndex })
  232. } catch (e) {
  233. // If not found - fallback to chain search
  234. const entityId = await this.findEntityIdByUniqueQuery(uniquePropVal, className)
  235. return createType('ParametrizedPropertyValue', {
  236. InputPropertyValue: { Single: { Reference: entityId } },
  237. })
  238. }
  239. }
  240. // parseEntityInput Overloads
  241. private parseEntityInput(entityInput: Record<string, any>, schema: AddClassSchema): Promise<number>
  242. private parseEntityInput(
  243. entityInput: Record<string, any>,
  244. schema: AddClassSchema,
  245. updatedEntityId: number
  246. ): Promise<void>
  247. // Parse entity input. Speficy "updatedEntityId" only if want to parse into update operation!
  248. private async parseEntityInput(
  249. entityInput: Record<string, any>,
  250. schema: AddClassSchema,
  251. updatedEntityId?: number
  252. ): Promise<void | number> {
  253. const parametrizedPropertyValues = await this.createParametrizedPropertyValues(
  254. entityInput,
  255. schema,
  256. async (property, value) => {
  257. // Custom handler for references
  258. const { property_type: propertyType } = property
  259. if (isSingle(propertyType) && isReference(propertyType.Single)) {
  260. const refEntitySchema = this.schemaByClassName(propertyType.Single.Reference.className)
  261. if (Object.keys(value).includes('new')) {
  262. const entityIndex = await this.parseEntityInput(value.new, refEntitySchema)
  263. return createType('ParametrizedPropertyValue', { InternalEntityJustAdded: entityIndex })
  264. } else if (Object.keys(value).includes('existing')) {
  265. return this.existingEntityQueryToParametrizedPropertyValue(refEntitySchema.className, value.existing)
  266. }
  267. }
  268. return undefined
  269. }
  270. )
  271. if (updatedEntityId) {
  272. // Update operation
  273. this.updateEntityPropertyValuesOperations.push(
  274. createType('OperationType', {
  275. UpdatePropertyValues: {
  276. entity_id: { ExistingEntity: updatedEntityId },
  277. new_parametrized_property_values: parametrizedPropertyValues,
  278. },
  279. })
  280. )
  281. } else {
  282. // Add operations (createEntity, AddSchemaSupportToEntity)
  283. const createEntityOperationIndex = this.createEntityOperations.length
  284. const classId = await this.getClassIdByName(schema.className)
  285. this.createEntityOperations.push(createType('OperationType', { CreateEntity: { class_id: classId } }))
  286. this.addSchemaToEntityOprations.push(
  287. createType('OperationType', {
  288. AddSchemaSupportToEntity: {
  289. schema_id: 0,
  290. entity_id: { InternalEntityJustAdded: createEntityOperationIndex },
  291. parametrized_property_values: parametrizedPropertyValues,
  292. },
  293. })
  294. )
  295. // Return CreateEntity operation index
  296. return createEntityOperationIndex
  297. }
  298. }
  299. private reset() {
  300. this.entityIndexByUniqueQueryMap = new Map<string, number>()
  301. this.classIdByNameMap = new Map<string, number>()
  302. this.createEntityOperations = []
  303. this.addSchemaToEntityOprations = []
  304. this.updateEntityPropertyValuesOperations = []
  305. this.entityByUniqueQueryCurrentIndex = 0
  306. }
  307. public async getEntityBatchOperations() {
  308. // First - create entityUniqueQueryMap to allow referencing any entity at any point
  309. this.batchInputs.forEach((batch) => {
  310. const entitySchema = this.schemaByClassName(batch.className)
  311. batch.entries.forEach((entityInput) => this.includeEntityInputInUniqueQueryMap(entityInput, entitySchema))
  312. })
  313. // Then - parse into actual operations
  314. for (const batch of this.batchInputs) {
  315. const entitySchema = this.schemaByClassName(batch.className)
  316. for (const entityInput of batch.entries) {
  317. await this.parseEntityInput(entityInput, entitySchema)
  318. }
  319. }
  320. const operations = [...this.createEntityOperations, ...this.addSchemaToEntityOprations]
  321. this.reset()
  322. return operations
  323. }
  324. public async getEntityUpdateOperations(
  325. input: Record<string, any>,
  326. className: string,
  327. entityId: number
  328. ): Promise<OperationType[]> {
  329. const schema = this.schemaByClassName(className)
  330. await this.parseEntityInput(input, schema, entityId)
  331. const operations = [
  332. ...this.createEntityOperations,
  333. ...this.addSchemaToEntityOprations,
  334. ...this.updateEntityPropertyValuesOperations,
  335. ]
  336. this.reset()
  337. return operations
  338. }
  339. public async parseAddClassSchemaExtrinsic(inputData: AddClassSchema) {
  340. const classId = await this.getClassIdByName(inputData.className)
  341. const newProperties = await Promise.all(
  342. inputData.newProperties.map(async (p) => ({
  343. ...p,
  344. // Parse different format for Reference (and potentially other propTypes in the future)
  345. property_type: (await this.parsePropertyType(p.property_type)).toJSON(),
  346. }))
  347. )
  348. return this.api.tx.contentDirectory.addClassSchema(
  349. classId,
  350. new (JoyBTreeSet(PropertyId))(this.api.registry, inputData.existingProperties),
  351. newProperties
  352. )
  353. }
  354. public parseCreateClassExtrinsic(inputData: CreateClass) {
  355. return this.api.tx.contentDirectory.createClass(
  356. inputData.name,
  357. inputData.description,
  358. inputData.class_permissions || {},
  359. inputData.maximum_entities_count,
  360. inputData.default_entity_creation_voucher_upper_bound
  361. )
  362. }
  363. public async getAddSchemaExtrinsics() {
  364. return await Promise.all(this.schemaInputs.map((data) => this.parseAddClassSchemaExtrinsic(data)))
  365. }
  366. public getCreateClassExntrinsics() {
  367. return this.classInputs.map((data) => this.parseCreateClassExtrinsic(data))
  368. }
  369. // Helper parser for "standalone" extrinsics like addSchemaSupportToEntity / updateEntityPropertyValues
  370. public async parseToInputEntityValuesMap(
  371. inputData: SimpleEntityInput,
  372. className: string
  373. ): Promise<InputEntityValuesMap> {
  374. await this.parseEntityInput(inputData, this.schemaByClassName(className))
  375. const inputPropValMap = new Map<PropertyId, InputPropertyValue>()
  376. const [operation] = this.addSchemaToEntityOprations
  377. operation
  378. .asType('AddSchemaSupportToEntity')
  379. .parametrized_property_values /* First we need to sort by propertyId, since otherwise there will be issues
  380. when encoding the BTreeMap (similar to BTreeSet) */
  381. .sort((a, b) => a.in_class_index.toNumber() - b.in_class_index.toNumber())
  382. .map((pcpv) => {
  383. inputPropValMap.set(pcpv.in_class_index, pcpv.value.asType('InputPropertyValue'))
  384. })
  385. this.reset()
  386. return createType('InputEntityValuesMap', inputPropValMap)
  387. }
  388. }