InputParser.ts 15 KB

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