Browse Source

Merge branch 'cd-schemas-lib' into cli-media

Leszek Wiesner 4 years ago
parent
commit
ffe2c52e99

+ 128 - 1
content-directory-schemas/README.md

@@ -1,4 +1,4 @@
-# Content directory json schemas and inputs
+# Content directory tooling
 
 ## Definitions
 
@@ -143,6 +143,133 @@ Besides that, a Typescript code can be written to generate some inputs (ie. usin
 
 There are a lot of other potential use-cases, but for the purpose of this documentation it should be enough to mention there exists this very easy way of converting `.schema.json` files into Typescript interfaces.
 
+## Using as library
+
+The `content-directory-schemas` directory of the monorepo is constructed in such a way, that it should be possible to use it as library and import from it json schemas, types (mentioned in `Typescript support` section) and tools to, for example, convert entity input like this described in the `Entity batches` section into `CreateEntity`, `AddSchemaSupportToEntity` and/or `UpdateEntityPropertyValues` operations.
+
+### Examples
+
+The best way to ilustrate this would be by providing some examples:
+
+#### Creating a channel
+```
+  import { InputParser } from 'cd-schemas'
+  import { ChannelEntity } from 'cd-schemas/types/entities/ChannelEntity'
+  // Other imports...
+
+  async main() {
+    // Initialize the api, SENDER_KEYPAIR and SENDER_MEMBER_ID...
+
+    const channel: ChannelEntity = {
+      title: 'Example channel',
+      description: 'This is an example channel',
+      language: { existing: { code: 'EN' } },
+      coverPhotoUrl: '',
+      avatarPhotoURL: '',
+      isPublic: true,
+    }
+
+    const parser = InputParser.createWithKnownSchemas(api, [
+      {
+        className: 'Channel',
+        entries: [channel],
+      },
+    ])
+
+    const operations = await parser.getEntityBatchOperations()
+    await api.tx.contentDirectory
+      .transaction({ Member: SENDER_MEMBER_ID }, operations)
+      .signAndSend(SENDER_KEYPAIR)
+  }
+```
+__Full example with comments can be found in `content-directory-schemas/examples/createChannel.ts` and ran with `yarn workspace cd-schemas example:createChannel`__
+
+#### Creating a video
+```
+import { InputParser } from 'cd-schemas'
+import { VideoEntity } from 'cd-schemas/types/entities/VideoEntity'
+// ...
+
+async main() {
+  // ...
+
+  const video: VideoEntity = {
+    title: 'Example video',
+    description: 'This is an example video',
+    language: { existing: { code: 'EN' } },
+    category: { existing: { name: 'Education' } },
+    channel: { existing: { title: 'Example channel' } },
+    media: {
+      new: {
+        encoding: { existing: { name: 'H.263_MP4' } },
+        pixelHeight: 600,
+        pixelWidth: 800,
+        location: {
+          new: {
+            httpMediaLocation: {
+              new: { url: 'https://testnet.joystream.org/' },
+            },
+          },
+        },
+      },
+    },
+    license: {
+      new: {
+        knownLicense: {
+          existing: { code: 'CC_BY' },
+        },
+      },
+    },
+    duration: 3600,
+    thumbnailURL: '',
+    isExplicit: false,
+    isPublic: true,
+  }
+
+  const parser = InputParser.createWithKnownSchemas(api, [
+    {
+      className: 'Video',
+      entries: [video],
+    },
+  ])
+
+  const operations = await parser.getEntityBatchOperations()
+  await api.tx.contentDirectory
+    .transaction({ Member: SENDER_MEMBER_ID }, operations)
+    .signAndSend(SENDER_KEYPAIR)
+}
+```
+__Full example with comments can be found in `content-directory-schemas/examples/createVideo.ts` and ran with `yarn workspace cd-schemas example:createChannel`__
+
+#### Update channel title
+
+Note that updates are currently very limitied (ie. the `new` and `existing` keywords are not supported for references etc.)
+
+```
+import { InputParser } from 'cd-schemas'
+import { ChannelEntity } from 'cd-schemas/types/entities/ChannelEntity'
+// ...
+
+async function main() {
+  // ...
+
+  const channelUpdateInput: Partial<ChannelEntity> = {
+    title: 'Updated channel title',
+  }
+
+  const parser = InputParser.createWithKnownSchemas(api)
+
+  const CHANNEL_ID = await parser.findEntityIdByUniqueQuery({ title: 'Example channel' }, 'Channel')
+
+  const updateOperation = await parser.createEntityUpdateOperation(channelUpdateInput, 'Channel', CHANNEL_ID)
+
+  await api.tx.contentDirectory
+    .transaction({ Member: SENDER_MEMBER_ID }, [updateOperation])
+    .signAndSend(SENDER_KEYPAIR)
+}
+```
+__Full example with comments can be found in `content-directory-schemas/examples/updateChannelTitle.ts` and ran with `yarn workspace cd-schemas example:updateChannelTitle`__
+
 ## Current limitations
 
 Some limitations that should be dealt with in the nearest future:

+ 52 - 0
content-directory-schemas/examples/createChannel.ts

@@ -0,0 +1,52 @@
+import { ApiPromise, WsProvider } from '@polkadot/api'
+import { types as joyTypes } from '@joystream/types'
+import { Keyring } from '@polkadot/keyring'
+// Import input parser and channel entity from cd-schemas (we use it as library here)
+import { InputParser } from 'cd-schemas'
+import { ChannelEntity } from 'cd-schemas/types/entities/ChannelEntity'
+
+async function main() {
+  // Initialize the api
+  const provider = new WsProvider('ws://127.0.0.1:9944')
+  const api = await ApiPromise.create({ provider, types: joyTypes })
+
+  // Get Alice keypair
+  const keyring = new Keyring()
+  keyring.addFromUri('//Alice', undefined, 'sr25519')
+  const [ALICE] = keyring.getPairs()
+
+  const channel: ChannelEntity = {
+    title: 'Example channel',
+    description: 'This is an example channel',
+    // We can use "existing" syntax to reference either an on-chain entity or other entity that's part of the same batch.
+    // Here we reference language that we assume was added by initialization script (initialize:dev), as it is part of
+    // input/entityBatches/LanguageBatch.json
+    language: { existing: { code: 'EN' } },
+    coverPhotoUrl: '',
+    avatarPhotoURL: '',
+    isPublic: true,
+  }
+  // Create the parser with known entity schemas (the ones in content-directory-schemas/inputs)
+  const parser = InputParser.createWithKnownSchemas(
+    api,
+    // The second argument is an array of entity batches, following standard entity batch syntax ({ className, entries }):
+    [
+      {
+        className: 'Channel',
+        entries: [channel], // We could specify multiple entries here, but in this case we only need one
+      },
+    ]
+  )
+  // We parse the input into CreateEntity and AddSchemaSupportToEntity operations
+  const operations = await parser.getEntityBatchOperations()
+  await api.tx.contentDirectory
+    .transaction(
+      { Member: 0 }, // We use member with id 0 as actor (in this case we assume this is Alice)
+      operations // We provide parsed operations as second argument
+    )
+    .signAndSend(ALICE)
+}
+
+main()
+  .then(() => process.exit())
+  .catch(console.error)

+ 76 - 0
content-directory-schemas/examples/createVideo.ts

@@ -0,0 +1,76 @@
+import { ApiPromise, WsProvider } from '@polkadot/api'
+import { types as joyTypes } from '@joystream/types'
+import { Keyring } from '@polkadot/keyring'
+// Import input parser and video entity from cd-schemas (we use it as library here)
+import { InputParser } from 'cd-schemas'
+import { VideoEntity } from 'cd-schemas/types/entities/VideoEntity'
+
+async function main() {
+  // Initialize the api
+  const provider = new WsProvider('ws://127.0.0.1:9944')
+  const api = await ApiPromise.create({ provider, types: joyTypes })
+
+  // Get Alice keypair
+  const keyring = new Keyring()
+  keyring.addFromUri('//Alice', undefined, 'sr25519')
+  const [ALICE] = keyring.getPairs()
+
+  const video: VideoEntity = {
+    title: 'Example video',
+    description: 'This is an example video',
+    // We reference existing language and category by their unique properties with "existing" syntax
+    // (those referenced here are part of inputs/entityBatches)
+    language: { existing: { code: 'EN' } },
+    category: { existing: { name: 'Education' } },
+    // We use the same "existing" syntax to reference a channel by unique property (title)
+    // In this case it's a channel that we created in createChannel example
+    channel: { existing: { title: 'Example channel' } },
+    media: {
+      // We use "new" syntax to sygnalize we want to create a new VideoMedia entity that will be related to this Video entity
+      new: {
+        // We use "exisiting" enconding from inputs/entityBatches/VideoMediaEncodingBatch.json
+        encoding: { existing: { name: 'H.263_MP4' } },
+        pixelHeight: 600,
+        pixelWidth: 800,
+        // We create nested VideoMedia->MediaLocation->HttpMediaLocation relations using the "new" syntax
+        location: { new: { httpMediaLocation: { new: { url: 'https://testnet.joystream.org/' } } } },
+      },
+    },
+    // Here we use combined "new" and "existing" syntaxes to create Video->License->KnownLicense relations
+    license: {
+      new: {
+        knownLicense: {
+          // This license can be found in inputs/entityBatches/KnownLicenseBatch.json
+          existing: { code: 'CC_BY' },
+        },
+      },
+    },
+    duration: 3600,
+    thumbnailURL: '',
+    isExplicit: false,
+    isPublic: true,
+  }
+  // Create the parser with known entity schemas (the ones in content-directory-schemas/inputs)
+  const parser = InputParser.createWithKnownSchemas(
+    api,
+    // The second argument is an array of entity batches, following standard entity batch syntax ({ className, entries }):
+    [
+      {
+        className: 'Video',
+        entries: [video], // We could specify multiple entries here, but in this case we only need one
+      },
+    ]
+  )
+  // We parse the input into CreateEntity and AddSchemaSupportToEntity operations
+  const operations = await parser.getEntityBatchOperations()
+  await api.tx.contentDirectory
+    .transaction(
+      { Member: 0 }, // We use member with id 0 as actor (in this case we assume this is Alice)
+      operations // We provide parsed operations as second argument
+    )
+    .signAndSend(ALICE)
+}
+
+main()
+  .then(() => process.exit())
+  .catch(console.error)

+ 47 - 0
content-directory-schemas/examples/updateChannelTitle.ts

@@ -0,0 +1,47 @@
+import { ApiPromise, WsProvider } from '@polkadot/api'
+import { types as joyTypes } from '@joystream/types'
+import { Keyring } from '@polkadot/keyring'
+// Import input parser and channel entity from cd-schemas (we use it as library here)
+import { InputParser } from 'cd-schemas'
+import { ChannelEntity } from 'cd-schemas/types/entities/ChannelEntity'
+
+async function main() {
+  // Initialize the api
+  const provider = new WsProvider('ws://127.0.0.1:9944')
+  const api = await ApiPromise.create({ provider, types: joyTypes })
+
+  // Get Alice keypair
+  const keyring = new Keyring()
+  keyring.addFromUri('//Alice', undefined, 'sr25519')
+  const [ALICE] = keyring.getPairs()
+
+  // Create partial channel entity, only containing the fields we wish to update
+  const channelUpdateInput: Partial<ChannelEntity> = {
+    title: 'Updated channel title',
+  }
+
+  // Create the parser with known entity schemas (the ones in content-directory-schemas/inputs)
+  const parser = InputParser.createWithKnownSchemas(api)
+
+  // We can reuse InputParser's `findEntityIdByUniqueQuery` method to find entityId of the channel we
+  // created in ./createChannel.ts example (normally we would probably use some other way to do it, ie.: query node)
+  const CHANNEL_ID = await parser.findEntityIdByUniqueQuery({ title: 'Example channel' }, 'Channel')
+
+  // Use createEntityUpdateOperation to parse the input
+  const updateOperation = await parser.createEntityUpdateOperation(
+    channelUpdateInput,
+    'Channel', // Class name
+    CHANNEL_ID // Id of the entity we want to update
+  )
+
+  await api.tx.contentDirectory
+    .transaction(
+      { Member: 0 }, // We use member with id 0 as actor (in this case we assume this is Alice)
+      [updateOperation] // The only operation we execute in this transaction is a single updateOperation
+    )
+    .signAndSend(ALICE)
+}
+
+main()
+  .then(() => process.exit())
+  .catch(console.error)

+ 15 - 2
content-directory-schemas/inputs/entityBatches/ContentCategoryBatch.json

@@ -1,7 +1,20 @@
 {
   "className": "ContentCategory",
   "entries": [
-    { "name": "Cartoon", "description": "Content which is a cartoon or related to cartoons" },
-    { "name": "Sports", "description": "Content related to sports" }
+    { "name": "Film & Animation" },
+    { "name": "Autos & Vehicles" },
+    { "name": "Music" },
+    { "name": "Pets & Animals" },
+    { "name": "Sports" },
+    { "name": "Travel & Events" },
+    { "name": "Gaming" },
+    { "name": "People & Blogs" },
+    { "name": "Comedy" },
+    { "name": "Entertainment" },
+    { "name": "News & Politics" },
+    { "name": "Howto & Style" },
+    { "name": "Education" },
+    { "name": "Science & Technology" },
+    { "name": "Nonprofits & Activism" }
   ]
 }

+ 36 - 1
content-directory-schemas/inputs/entityBatches/LanguageBatch.json

@@ -3,6 +3,41 @@
   "entries": [
     { "code": "EN", "name": "English" },
     { "code": "RU", "name": "Russian" },
-    { "code": "DE", "name": "German" }
+    { "code": "DE", "name": "German" },
+    { "code": "IT", "name": "Italian" },
+    { "code": "ES", "name": "Spanish" },
+    { "code": "UK", "name": "Ukrainian" },
+    { "code": "CZ", "name": "Czech" },
+    { "code": "PL", "name": "Polish" },
+    { "code": "RO", "name": "Romanian" },
+    { "code": "NO", "name": "Norwegian" },
+    { "code": "AR", "name": "Arabic" },
+    { "code": "BG", "name": "Bulgarian" },
+    { "code": "ZH", "name": "Chinese" },
+    { "code": "HR", "name": "Croatian" },
+    { "code": "DA", "name": "Danish" },
+    { "code": "NL", "name": "Dutch" },
+    { "code": "FI", "name": "Finnish" },
+    { "code": "FR", "name": "French" },
+    { "code": "EL", "name": "Greek" },
+    { "code": "HI", "name": "Hindi" },
+    { "code": "HU", "name": "Hungarian" },
+    { "code": "ID", "name": "Indonesian" },
+    { "code": "GA", "name": "Irish" },
+    { "code": "IS", "name": "Icelandic" },
+    { "code": "JA", "name": "Japanese" },
+    { "code": "KO", "name": "Korean" },
+    { "code": "LT", "name": "Lithuanian" },
+    { "code": "MK", "name": "Macedonian" },
+    { "code": "PT", "name": "Portuguese" },
+    { "code": "SR", "name": "Serbian" },
+    { "code": "SK", "name": "Slovak" },
+    { "code": "SL", "name": "Slovenian" },
+    { "code": "SV", "name": "Swedish" },
+    { "code": "TH", "name": "Thai" },
+    { "code": "BO", "name": "Tibetan" },
+    { "code": "TR", "name": "Turkish" },
+    { "code": "VI", "name": "Vietnamese" },
+    { "code": "CY", "name": "Welsh" }
   ]
 }

+ 4 - 4
content-directory-schemas/inputs/entityBatches/VideoBatch.json

@@ -5,14 +5,14 @@
       "title": "Caminades 2",
       "description": "Caminandes 2: Gran Dillama",
       "language": { "existing": { "code": "EN" } },
-      "category": { "existing": { "name": "Cartoon" } },
+      "category": { "existing": { "name": "Film & Animation" } },
       "channel": { "existing": { "title": "Joystream Cartoons" } },
       "duration": 146,
       "hasMarketing": false,
       "isPublic": true,
       "media": {
         "new": {
-          "encoding": { "existing": { "name": "MPEG4" } },
+          "encoding": { "existing": { "name": "H.264_MP4" } },
           "location": {
             "new": {
               "httpMediaLocation": {
@@ -34,14 +34,14 @@
       "title": "Caminades 3",
       "description": "Caminandes 3: Llamigos",
       "language": { "existing": { "code": "EN" } },
-      "category": { "existing": { "name": "Cartoon" } },
+      "category": { "existing": { "name": "Film & Animation" } },
       "channel": { "existing": { "title": "Joystream Cartoons" } },
       "duration": 150,
       "hasMarketing": false,
       "isPublic": true,
       "media": {
         "new": {
-          "encoding": { "existing": { "name": "MPEG4" } },
+          "encoding": { "existing": { "name": "H.264_MP4" } },
           "location": {
             "new": {
               "httpMediaLocation": {

+ 24 - 1
content-directory-schemas/inputs/entityBatches/VideoMediaEncodingBatch.json

@@ -1,4 +1,27 @@
 {
   "className": "VideoMediaEncoding",
-  "entries": [{ "name": "MPEG4" }]
+  "entries": [
+    { "name": "H.263_MP4" },
+    { "name": "H.263_3GP" },
+    { "name": "H.263_AVI" },
+    { "name": "H.264_MP4" },
+    { "name": "H.264_3GP" },
+    { "name": "H.264_AVI" },
+    { "name": "H.264_MKV" },
+    { "name": "H.265_MP4" },
+    { "name": "H.265_3GP" },
+    { "name": "H.265_AVI" },
+    { "name": "VP8_WEBM" },
+    { "name": "VP8_MP4" },
+    { "name": "VP8_AVI" },
+    { "name": "VP8_MKV" },
+    { "name": "VP9_WEBM" },
+    { "name": "VP9_MP4" },
+    { "name": "VP9_AVI" },
+    { "name": "VP9_MKV" },
+    { "name": "AV1_MP4" },
+    { "name": "MVC_MP4" },
+    { "name": "MVC_3GP" },
+    { "name": "MVC_MKV" }
+  ]
 }

+ 4 - 1
content-directory-schemas/package.json

@@ -16,7 +16,10 @@
     "generate:all": "yarn generate:entity-schemas && yarn generate:types",
     "initialize:alice-as-lead": "ts-node ./scripts/devInitAliceLead.ts",
     "initialize:content-dir": "ts-node ./scripts/initializeContentDir.ts",
-    "initialize:dev": "yarn initialize:alice-as-lead && yarn initialize:content-dir"
+    "initialize:dev": "yarn initialize:alice-as-lead && yarn initialize:content-dir",
+    "example:createChannel": "ts-node ./examples/createChannel.ts",
+    "example:createVideo": "ts-node ./examples/createVideo.ts",
+    "example:updateChannelTitle": "ts-node ./examples/updateChannelTitle.ts"
   },
   "dependencies": {
     "ajv": "6.12.5",

+ 1 - 1
content-directory-schemas/scripts/initializeContentDir.ts

@@ -54,7 +54,7 @@ async function main() {
       4
     )
   )
-  console.log('Sending Transaction extrinsic...')
+  console.log(`Sending Transaction extrinsic (${entityOperations.length} operations)...`)
   await txHelper.sendAndCheck(
     ALICE,
     [api.tx.contentDirectory.transaction({ Lead: null }, entityOperations)],

+ 128 - 37
content-directory-schemas/src/helpers/InputParser.ts

@@ -7,6 +7,9 @@ import {
   ParametrizedPropertyValue,
   PropertyId,
   PropertyType,
+  EntityId,
+  Entity,
+  ParametrizedClassPropertyValue,
 } from '@joystream/types/content-directory'
 import { isSingle, isReference } from './propertyType'
 import { ApiPromise } from '@polkadot/api'
@@ -23,9 +26,11 @@ export class InputParser {
   private createEntityOperations: OperationType[] = []
   private addSchemaToEntityOprations: OperationType[] = []
   private entityIndexByUniqueQueryMap = new Map<string, number>()
+  private entityIdByUniqueQueryMap = new Map<string, number>()
   private entityByUniqueQueryCurrentIndex = 0
   private classIdByNameMap = new Map<string, number>()
   private classMapInitialized = false
+  private entityIdByUniqueQueryMapInitialized = false
 
   static createWithKnownSchemas(api: ApiPromise, entityBatches?: EntityBatch[]) {
     return new InputParser(
@@ -59,6 +64,56 @@ export class InputParser {
     this.classMapInitialized = true
   }
 
+  // Initialize entityIdByUniqueQueryMap with entities fetched from the chain
+  private async initializeEntityIdByUniqueQueryMap() {
+    if (this.entityIdByUniqueQueryMapInitialized) {
+      return
+    }
+
+    await this.initializeClassMap() // Initialize if not yet initialized
+
+    // Get entity entries
+    const entityEntries: [EntityId, Entity][] = (
+      await this.api.query.contentDirectory.entityById.entries()
+    ).map(([storageKey, entity]) => [storageKey.args[0] as EntityId, entity])
+
+    entityEntries.forEach(([entityId, entity]) => {
+      const classId = entity.class_id.toNumber()
+      const className = Array.from(this.classIdByNameMap.entries()).find(([, id]) => id === classId)?.[0]
+      if (!className) {
+        // Class not found - skip
+        return
+      }
+      let schema: AddClassSchema
+      try {
+        schema = this.schemaByClassName(className)
+      } catch (e) {
+        // Input schema not found - skip
+        return
+      }
+      const valuesEntries = Array.from(entity.getField('values').entries())
+      schema.newProperties.forEach(({ name, unique }, index) => {
+        if (!unique) {
+          return // Skip non-unique properties
+        }
+        const storedValue = valuesEntries.find(([propertyId]) => propertyId.toNumber() === index)?.[1]
+        if (
+          storedValue === undefined ||
+          // If unique value is Bool, it's almost definitely empty, so we skip it
+          (storedValue.isOfType('Single') && storedValue.asType('Single').isOfType('Bool'))
+        ) {
+          // Skip empty values (not all unique properties are required)
+          return
+        }
+        const simpleValue = storedValue.getValue().toJSON()
+        const hash = this.getUniqueQueryHash({ [name]: simpleValue }, schema.className)
+        this.entityIdByUniqueQueryMap.set(hash, entityId.toNumber())
+      })
+    })
+
+    this.entityIdByUniqueQueryMapInitialized = true
+  }
+
   private schemaByClassName(className: string) {
     const foundSchema = this.schemaInputs.find((data) => data.className === className)
     if (!foundSchema) {
@@ -84,6 +139,20 @@ export class InputParser {
     return foundIndex
   }
 
+  // Seatch for entity by { [uniquePropName]: [uniquePropVal] } on chain
+  async findEntityIdByUniqueQuery(uniquePropVal: Record<string, any>, className: string): Promise<number> {
+    await this.initializeEntityIdByUniqueQueryMap()
+    const hash = this.getUniqueQueryHash(uniquePropVal, className)
+    const foundId = this.entityIdByUniqueQueryMap.get(hash)
+    if (foundId === undefined) {
+      throw new Error(
+        `findEntityIdByUniqueQuery failed for class ${className} and query: ${JSON.stringify(uniquePropVal)}`
+      )
+    }
+
+    return foundId
+  }
+
   private getClassIdByName(className: string): number {
     const classId = this.classIdByNameMap.get(className)
     if (classId === undefined) {
@@ -129,46 +198,66 @@ export class InputParser {
     ++this.entityByUniqueQueryCurrentIndex
   }
 
-  private createParametrizedPropertyValues(
+  private async createParametrizedPropertyValues(
     entityInput: Record<string, any>,
     schema: AddClassSchema,
-    customHandler?: (property: Property, value: any) => ParametrizedPropertyValue | undefined
-  ) {
-    return Object.entries(entityInput)
-      .filter(([, pValue]) => pValue !== undefined)
-      .map(([propertyName, propertyValue]) => {
-        const schemaPropertyIndex = schema.newProperties.findIndex((p) => p.name === propertyName)
-        const schemaProperty = schema.newProperties[schemaPropertyIndex]
-
-        let value = customHandler && customHandler(schemaProperty, propertyValue)
-        if (value === undefined) {
-          value = createType('ParametrizedPropertyValue', {
-            InputPropertyValue: this.parsePropertyType(schemaProperty.property_type).toInputPropertyValue(
-              propertyValue
-            ),
-          })
-        }
+    customHandler?: (property: Property, value: any) => Promise<ParametrizedPropertyValue | undefined>
+  ): Promise<ParametrizedClassPropertyValue[]> {
+    const filteredInput = Object.entries(entityInput).filter(([, pValue]) => pValue !== undefined)
+    const parametrizedClassPropValues: ParametrizedClassPropertyValue[] = []
 
-        return { in_class_index: schemaPropertyIndex, value }
-      })
+    for (const [propertyName, propertyValue] of filteredInput) {
+      const schemaPropertyIndex = schema.newProperties.findIndex((p) => p.name === propertyName)
+      const schemaProperty = schema.newProperties[schemaPropertyIndex]
+
+      let value = customHandler && (await customHandler(schemaProperty, propertyValue))
+      if (value === undefined) {
+        value = createType('ParametrizedPropertyValue', {
+          InputPropertyValue: this.parsePropertyType(schemaProperty.property_type)
+            .toInputPropertyValue(propertyValue)
+            .toJSON() as any,
+        })
+      }
+
+      parametrizedClassPropValues.push(
+        createType('ParametrizedClassPropertyValue', {
+          in_class_index: schemaPropertyIndex,
+          value: value.toJSON() as any,
+        })
+      )
+    }
+
+    return parametrizedClassPropValues
   }
 
-  private parseEntityInput(entityInput: Record<string, any>, schema: AddClassSchema) {
-    const parametrizedPropertyValues = this.createParametrizedPropertyValues(entityInput, schema, (property, value) => {
-      // Custom handler for references
-      const { property_type: propertyType } = property
-      if (isSingle(propertyType) && isReference(propertyType.Single)) {
-        const refEntitySchema = this.schemaByClassName(propertyType.Single.Reference.className)
-        if (Object.keys(value).includes('new')) {
-          const entityIndex = this.parseEntityInput(value.new, refEntitySchema)
-          return createType('ParametrizedPropertyValue', { InternalEntityJustAdded: entityIndex })
-        } else if (Object.keys(value).includes('existing')) {
-          const entityIndex = this.findEntityIndexByUniqueQuery(value.existing, refEntitySchema.className)
-          return createType('ParametrizedPropertyValue', { InternalEntityJustAdded: entityIndex })
+  private async parseEntityInput(entityInput: Record<string, any>, schema: AddClassSchema) {
+    const parametrizedPropertyValues = await this.createParametrizedPropertyValues(
+      entityInput,
+      schema,
+      async (property, value) => {
+        // Custom handler for references
+        const { property_type: propertyType } = property
+        if (isSingle(propertyType) && isReference(propertyType.Single)) {
+          const refEntitySchema = this.schemaByClassName(propertyType.Single.Reference.className)
+          if (Object.keys(value).includes('new')) {
+            const entityIndex = await this.parseEntityInput(value.new, refEntitySchema)
+            return createType('ParametrizedPropertyValue', { InternalEntityJustAdded: entityIndex })
+          } else if (Object.keys(value).includes('existing')) {
+            try {
+              const entityIndex = this.findEntityIndexByUniqueQuery(value.existing, refEntitySchema.className)
+              return createType('ParametrizedPropertyValue', { InternalEntityJustAdded: entityIndex })
+            } catch (e) {
+              // Fallback to chain search
+              const entityId = await this.findEntityIdByUniqueQuery(value.existing, refEntitySchema.className)
+              return createType('ParametrizedPropertyValue', {
+                InputPropertyValue: { Single: { Reference: entityId } },
+              })
+            }
+          }
         }
+        return undefined
       }
-      return undefined
-    })
+    )
 
     // Add operations
     const createEntityOperationIndex = this.createEntityOperations.length
@@ -204,10 +293,12 @@ export class InputParser {
       batch.entries.forEach((entityInput) => this.includeEntityInputInUniqueQueryMap(entityInput, entitySchema))
     })
     // Then - parse into actual operations
-    this.batchInputs.forEach((batch) => {
+    for (const batch of this.batchInputs) {
       const entitySchema = this.schemaByClassName(batch.className)
-      batch.entries.forEach((entityInput) => this.parseEntityInput(entityInput, entitySchema))
-    })
+      for (const entityInput of batch.entries) {
+        await this.parseEntityInput(entityInput, entitySchema)
+      }
+    }
 
     const operations = [...this.createEntityOperations, ...this.addSchemaToEntityOprations]
     this.reset()
@@ -222,7 +313,7 @@ export class InputParser {
   ): Promise<OperationType> {
     await this.initializeClassMap()
     const schema = this.schemaByClassName(className)
-    const parametrizedPropertyValues = this.createParametrizedPropertyValues(entityInput, schema)
+    const parametrizedPropertyValues = await this.createParametrizedPropertyValues(entityInput, schema)
 
     return createType('OperationType', {
       UpdatePropertyValues: {