diff --git a/SUMMARY.md b/SUMMARY.md index 5dd8383f8..ae63be441 100644 --- a/SUMMARY.md +++ b/SUMMARY.md @@ -1,31 +1,32 @@ # Table of contents -* [Hydra](README.md) -* [Hydra CLI](packages/hydra-cli/README.md) -* [Hydra Indexer](packages/hydra-indexer/README.md) -* [Hydra Indexer Gateway](packages/hydra-indexer-gateway/README.md) -* [Hydra Processor](packages/hydra-processor/README.md) -* [Hydra Typegen](packages/hydra-typegen/README.md) -* [Overview](docs/README.md) - * [Query Node Manifest](docs/manifest-spec.md) - * [Query Node Queries](docs/queries.md) - * [Pagination](docs/paginate-query-results.md) - * [Sorting](docs/sort-query-results.md) - * [Mappings](docs/mappings/README.md) - * [DatabaseManager](docs/mappings/databasemanager.md) - * [SubstrateEvent](docs/mappings/substrateevent.md) - * [Schema](docs/schema-spec/README.md) - * [The Goodies](docs/schema-spec/the-query-goodies.md) - * [Entities](docs/schema-spec/entities.md) - * [Enums](docs/schema-spec/enums.md) - * [Interfaces](docs/schema-spec/interfaces.md) - * [Algebraic types](docs/schema-spec/variant-types.md) - * [Full-text queries](docs/schema-spec/full-text-queries.md) - * [Entity Relationships](docs/schema-spec/entity-relationship.md) - * [Install Hydra](docs/install-hydra.md) - * [Tutorial](docs/quick-start.md) - * [GraphQL Entity Relationships](docs/graphql-entity-relationships.md) - * [Architecture](docs/architecture.md) -* [Migration to Hydra v2](migration-to-hydra-v2.md) -* [What's new in Hydra v3](announcing-hydra-v3.md) - +- [Hydra](README.md) +- [Hydra CLI](packages/hydra-cli/README.md) +- [Hydra Indexer](packages/hydra-indexer/README.md) +- [Hydra Indexer Gateway](packages/hydra-indexer-gateway/README.md) +- [Hydra Processor](packages/hydra-processor/README.md) +- [Hydra Typegen](packages/hydra-typegen/README.md) +- [Overview](docs/README.md) + - [Query Node Manifest](docs/manifest-spec.md) + - [Graphql Queries](docs/queries.md) + - [Pagination](docs/paginate-query-results.md) + - [Sorting](docs/sort-query-results.md) + - [Mappings](docs/mappings/README.md) + - [DatabaseManager](docs/mappings/databasemanager.md) + - [SubstrateEvent](docs/mappings/substrateevent.md) + - [Schema](docs/schema-spec/README.md) + - [The Goodies](docs/schema-spec/the-query-goodies.md) + - [Entities](docs/schema-spec/entities.md) + - [Enums](docs/schema-spec/enums.md) + - [Interfaces](docs/schema-spec/interfaces.md) + - [Algebraic types](docs/schema-spec/variant-types.md) + - [Full-text queries](docs/schema-spec/full-text-queries.md) + - [Entity Relationships](docs/schema-spec/entity-relationship.md) + - [Cross filtering](docs/schema-spec/cross-filters.md) + - [Variant relations](docs/schema-spec/variant-relations.md) + - [Install Hydra](docs/install-hydra.md) + - [Tutorial](docs/quick-start.md) + - [GraphQL Entity Relationships](docs/graphql-entity-relationships.md) + - [Architecture](docs/architecture.md) +- [Migration to Hydra v2](migration-to-hydra-v2.md) +- [What's new in Hydra v3](announcing-hydra-v3.md) diff --git a/announcing-hydra-v3.md b/announcing-hydra-v3.md index dce525c5e..6a6e37096 100644 --- a/announcing-hydra-v3.md +++ b/announcing-hydra-v3.md @@ -2,12 +2,12 @@ Hydra v3 is already in the works. Here are the main features planned: -* Support for filtering by relations -* Support for adding relations to variant types -* Ordering by multiple fields -* Multiple filters in the same query \(`AND` and `OR`\) -* Filters for mapping handlers: specify heights and runtime spec version range -* Import all model files from a single library. Instead of the cumbersome +- Support for filtering by relations +- Support for adding relations to variant types +- Ordering by multiple fields +- Multiple filters in the same query \(`AND` and `OR`\) +- Filters for mapping handlers: specify heights and runtime spec version range +- Import all model files from a single library. Instead of the cumbersome ```typescript import { MyEntity1 } from '../generated/graphql-server/my-entity2/my-entity2.model' @@ -19,4 +19,3 @@ write ```typescript import { MyEntity1, MyEntity2 } from '../generated/graphql-server/model' ``` - diff --git a/docs/schema-spec/variant-relations.md b/docs/schema-spec/variant-relations.md new file mode 100644 index 000000000..c09b965e7 --- /dev/null +++ b/docs/schema-spec/variant-relations.md @@ -0,0 +1,86 @@ +--- +description: Define variant types with relations +--- + +# Variant Relations + +Variant types support entity relationship in a different way unlike the normal entity relationship. There are some limitations with variant relations: + +- Only one-to-many and many-to-one relations are supported +- Reverse lookup is not supported + +Let's take a look an example: + +1. Schema: in the schema below there are two variant types with the relations + +```graphql +type BoughtMemberEvent @entity { + id: ID! + name: String + handle: String! +} + +type InvitedMemberEvent @entity { + id: ID! + name: String + handle: String! +} + +type MemberInvitation @variant { + event: InvitedMemberEvent! +} + +type MemberPurchase @variant { + event: BoughtMemberEvent! +} + +union MemberSource = MemberInvitation | MemberPurchase + +type Member @entity { + id: ID! + isVerified: Boolean! + handle: String! + source: MemberSource! +} +``` + +2. Mappings: insert data into database + +For variant relations to work an additional field is added to variant type which is db only field (which means it is not visible in the graphql API). This field is will be generated from relation field name + 'id' ie. in the schema above relation name is `event` so the auto generated field name is `eventId`. This field is not optional and mapping author must set it properly. + +```ts +async function handle_Member(db: DB, event: SubstrateEvent) { + // Create an event from BoughtMemberEvent or MemberInvitation and save to db + let event = new InvitedMemberEvent({ handle: 'joy' }) + event = await db.save(event) + + // Create variant instance and set eventId property + const invitation = new MemberInvitation() + // Auto generated property, it holds primary key of the related entity + invitation.eventId = event.id + + // Create new member and set the source property + const member = new Member({ handle: 'hydra', isVerified: true }) + member.source = invitation + await db.save(member) +} +``` + +3. Query: fetch all members' `source`: + +```graphql +query { + members { + source { + __typename + ... on MemberInvitation { + event { + id + name + handle + } + } + } + } +} +``` diff --git a/packages/hydra-cli/src/generate/ModelRenderer.ts b/packages/hydra-cli/src/generate/ModelRenderer.ts index c9319f8af..0ff685065 100644 --- a/packages/hydra-cli/src/generate/ModelRenderer.ts +++ b/packages/hydra-cli/src/generate/ModelRenderer.ts @@ -6,6 +6,7 @@ import * as utils from './utils' import { GraphQLEnumType } from 'graphql' import { AbstractRenderer } from './AbstractRenderer' import { withEnum } from './enum-context' +import { camelCase } from 'lodash' import { getRelationType } from '../model/Relation' const debug = Debug('qnode-cli:model-renderer') @@ -170,6 +171,30 @@ export class ModelRenderer extends AbstractRenderer { } } + /** + * Provides variant names for the fields that have union type, we need variant names for the union + * in order to fetch the data for variant relations and it is used by service.mst template + * @returns GeneratorContext + */ + withVariantNames(): GeneratorContext { + const variantNames = new Set() + const fieldVariantMap: { field: string; type: string }[] = [] + + for (const field of this.objType.fields.filter((f) => f.isUnion())) { + const union = this.model.lookupUnion(field.type) + + for (const type of union.types) { + type.fields.forEach((f) => { + if (f.isEntity()) { + variantNames.add(type.name) + fieldVariantMap.push({ field: camelCase(f.name), type: type.name }) + } + }) + } + } + return { variantNames: Array.from(variantNames), fieldVariantMap } + } + transform(): GeneratorContext { return { ...this.context, // this.getGeneratedFolderRelativePath(objType.name), @@ -183,6 +208,7 @@ export class ModelRenderer extends AbstractRenderer { ...this.withImportProps(), ...this.withFieldResolvers(), ...utils.withNames(this.objType), + ...this.withVariantNames(), } } } diff --git a/packages/hydra-cli/src/generate/VariantsRenderer.ts b/packages/hydra-cli/src/generate/VariantsRenderer.ts index c762f9787..21265ac2e 100644 --- a/packages/hydra-cli/src/generate/VariantsRenderer.ts +++ b/packages/hydra-cli/src/generate/VariantsRenderer.ts @@ -3,6 +3,7 @@ import { GeneratorContext } from './SourcesGenerator' import { AbstractRenderer } from './AbstractRenderer' import { ModelRenderer } from './ModelRenderer' import { withUnionType } from './union-context' +import { generateEntityImport } from './utils' export class VariantsRenderer extends AbstractRenderer { constructor(model: WarthogModel, context: GeneratorContext = {}) { @@ -11,11 +12,13 @@ export class VariantsRenderer extends AbstractRenderer { withImports(): { imports: string[] } { const moduleImports = new Set() - this.model.variants.forEach((v) => { - if (v.fields.find((f) => f.type === 'BigInt')) { - moduleImports.add(`import BN from 'bn.js'\n`) - } - }) + for (const variant of this.model.variants) { + variant.fields.map((f) => { + if (f.isEntity()) { + moduleImports.add(generateEntityImport(f.type)) + } + }) + } return { imports: Array.from(moduleImports) } } diff --git a/packages/hydra-cli/src/generate/field-context.ts b/packages/hydra-cli/src/generate/field-context.ts index 4279af9cd..7b7cf044a 100644 --- a/packages/hydra-cli/src/generate/field-context.ts +++ b/packages/hydra-cli/src/generate/field-context.ts @@ -86,6 +86,7 @@ export function buildFieldContext( ...withDerivedNames(f, entity), ...withDescription(f), ...withTransformer(f), + ...withArrayProp(f), } } @@ -95,6 +96,7 @@ export function withFieldTypeGuardProps(f: Field): GeneratorContext { is.scalar = f.isScalar() is.enum = f.isEnum() is.union = f.isUnion() + is.entity = f.isEntity() ;['mto', 'oto', 'otm', 'mtm'].map((s) => (is[s] = f.relation?.type === s)) return { is: is, @@ -180,6 +182,12 @@ export function withRelation(f: Field): GeneratorContext { } } +export function withArrayProp(f: Field): GeneratorContext { + return { + array: f.isList, + } +} + export function withTransformer(f: Field): GeneratorContext { if ( TYPE_FIELDS[f.columnType()] && diff --git a/packages/hydra-cli/src/model/Field.ts b/packages/hydra-cli/src/model/Field.ts index 66dfc68ba..02f1f6d99 100644 --- a/packages/hydra-cli/src/model/Field.ts +++ b/packages/hydra-cli/src/model/Field.ts @@ -75,4 +75,8 @@ export class Field { isUnion(): boolean { return this.modelType === ModelType.UNION } + + isEntity(): boolean { + return this.modelType === ModelType.ENTITY + } } diff --git a/packages/hydra-cli/src/templates/entities/service.ts.mst b/packages/hydra-cli/src/templates/entities/service.ts.mst index 7c3cb76be..7b3ae3155 100644 --- a/packages/hydra-cli/src/templates/entities/service.ts.mst +++ b/packages/hydra-cli/src/templates/entities/service.ts.mst @@ -5,6 +5,8 @@ import { BaseService, WhereInput } from 'warthog'; import { {{className}} } from './{{kebabName}}.model'; +import { {{#variantNames}} {{.}}, {{/variantNames}} } from '../variants/variants.model' + @Service('{{className}}Service') export class {{className}}Service extends BaseService<{{className}}> { constructor( @@ -32,8 +34,22 @@ export class {{className}}Service extends BaseService<{{className}}> { } {{/is.union}} {{/fields}} - - return super.find(where, orderBy, limit, offset, f); + + {{#has.union}} + let records = await super.find(where, orderBy, limit, offset, f); + if (records.length) { + {{#fields}} + {{#is.union}} + {{#fieldVariantMap}} + records = await {{type}}.fetchData{{field}}(records, '{{camelName}}') + {{/fieldVariantMap}} + {{/is.union}} + {{/fields}} + } + return records; + {{/has.union}} + + {{^has.union}} return super.find(where, orderBy, limit, offset, f); {{/has.union}} } } \ No newline at end of file diff --git a/packages/hydra-cli/src/templates/variants/variants.mst b/packages/hydra-cli/src/templates/variants/variants.mst index c921843c1..2da4c3339 100644 --- a/packages/hydra-cli/src/templates/variants/variants.mst +++ b/packages/hydra-cli/src/templates/variants/variants.mst @@ -10,8 +10,11 @@ import { EnumField, StringField } from 'warthog'; +import BN from 'bn.js' -{{#imports}} {{{.}}} {{/imports}} +{{#imports}} +{{{.}}}; +{{/imports}} {{#variants}} {{#fields}} @@ -23,6 +26,7 @@ import { {{/variants}} import { ObjectType, Field, createUnionType } from 'type-graphql'; +import { getRepository, In } from 'typeorm' {{#variants}} @ObjectType() @@ -30,6 +34,17 @@ export class {{name}} { public isTypeOf: string = '{{name}}'; {{#fields}} + {{#is.entity}} + @Field(() => {{^array}}{{tsType}}{{/array}} {{#array}}[{{tsType}}]{{/array}}, { + nullable: true, + {{#description}}description: `{{{description}}}`{{/description}} + }) + {{camelName}}?:{{tsType}}{{#array}}[]{{/array}}; + + // `id` of the related entity, it is required to make the relationship possible. + @StringField({ dbOnly: true {{#array}},array: true{{/array}} }) + {{camelName}}Id{{#array}}s{{/array}}!: string{{#array}}[]{{/array}}; + {{/is.entity}} {{#is.scalar}} @{{decorator}}({ @@ -56,6 +71,55 @@ export class {{name}} { {{/is.union}} {{/fields}} + + {{#fields}} + {{#is.entity}} + static async fetchData{{camelName}}(records: any, unionFieldName: string) { + {{#array}} + const data: any = {} + const ids: any[] = [] + records = records.filter( + (r: any) => (r[unionFieldName] as {{name}}).{{camelName}}Ids && (r[unionFieldName] as {{name}}).{{camelName}}Ids.length + ) + records.map((r: any) => ids.push(...(r[unionFieldName] as {{name}}).{{camelName}}Ids)) + + const relationData = await getRepository({{tsType}}).find({ + where: { id: In(ids) } + }) + relationData.map((r: any) => data[r.id] = r); + + for (const record of records) { + + for (const id of (record[unionFieldName] as {{name}}).{{camelName}}Ids) { + const m = data[id] + if (m) { + if (!(record[unionFieldName] as {{name}}).{{camelName}}) { + (record[unionFieldName] as {{name}}).{{camelName}} = [] + } + record[unionFieldName].{{camelName}}!.push(data[id]) + } + } + } + {{/array}} + + {{^array}} + const data: any = {} + const relationData = await getRepository({{tsType}}).find({ + where: { id: In(records.map((r: any) => (r[unionFieldName] as {{name}}).{{camelName}}Id)) } + }); + relationData.map((r: any) => data[r.id] = r); + + for (const record of records) { + const m = data[(record[unionFieldName] as {{name}}).{{camelName}}Id] + if (m) { + (record[unionFieldName] as {{name}}).{{camelName}} = m + } + } + {{/array}} + return records + } + {{/is.entity}} + {{/fields}} } {{/variants}} diff --git a/packages/hydra-cli/test/helpers/VariantsRenderer.test.ts b/packages/hydra-cli/test/helpers/VariantsRenderer.test.ts index 8b84e888f..dc7fa705e 100644 --- a/packages/hydra-cli/test/helpers/VariantsRenderer.test.ts +++ b/packages/hydra-cli/test/helpers/VariantsRenderer.test.ts @@ -80,4 +80,45 @@ describe('VariantsRenderer', () => { expect(rendered).include(`import BN from 'bn.js'`) }) + + it('Should generate data fetch method', () => { + const model = fromStringSchema(` + type BoughtMemberEvent @entity { + id: ID! + name: String + } + + type MemberInvitation @variant { + event: BoughtMemberEvent! + } + + type MemberPurchase @variant { + event: BoughtMemberEvent! + } + + union MemberSource = MemberInvitation | MemberPurchase + + type Member @entity { + id: ID! + handle: String! + source: MemberSource! + }`) + + const gen = new VariantsRenderer(model) + const rendered = gen.render(variantsTemplate) + expect(rendered).to.include( + `static async fetchDataevent(records: any, unionFieldName: string) {`, + 'should generate data fetching method' + ) + expect(rendered).to.include( + `@Field(() => BoughtMemberEvent`, + 'should add field decorator' + ) + expect(rendered).to.include( + ` + @StringField({ dbOnly: true }) + eventId!: string;`, + 'should generated additional field' + ) + }) })