diff --git a/packages/hydra-cli/package.json b/packages/hydra-cli/package.json index 9c3ce9b67..f4b637cc6 100644 --- a/packages/hydra-cli/package.json +++ b/packages/hydra-cli/package.json @@ -39,8 +39,7 @@ "postpack": "rm -f oclif.manifest.json", "lint": "eslint . --cache --ext .ts", "prepack": "rm -rf lib && tsc -b && cp -R ./src/templates ./lib/src/templates && oclif-dev manifest", - "test": "nyc --extension .ts mocha --forbid-only \"test/**/*.test.ts\"", - "version": "oclif-dev readme && git add README.md" + "test": "nyc --extension .ts mocha --forbid-only \"test/**/*.test.ts\"" }, "dependencies": { "@inquirer/input": "^0.0.13-alpha.0", diff --git a/packages/hydra-cli/src/generate/ModelRenderer.ts b/packages/hydra-cli/src/generate/ModelRenderer.ts index 77ddeb732..63f3cf75b 100644 --- a/packages/hydra-cli/src/generate/ModelRenderer.ts +++ b/packages/hydra-cli/src/generate/ModelRenderer.ts @@ -54,6 +54,16 @@ export class ModelRenderer extends AbstractRenderer { } } + withInterfaceRelationOptions(): GeneratorContext { + if (this.objType.isInterface !== true) { + return {} + } + return { + interfaceRelations: utils.interfaceRelations(this.objType), + interfaceEnumName: `${this.objType.name}TypeOptions`, + } + } + withEnums(): GeneratorContext { // we need to have a state to render exports only once const referncedEnums = new Set() @@ -70,11 +80,10 @@ export class ModelRenderer extends AbstractRenderer { } withFields(): GeneratorContext { - const fields: GeneratorContext[] = [] + const fields: GeneratorContext[] = this.objType.fields.map((f) => + buildFieldContext(f, this.objType) + ) - utils - .ownFields(this.objType) - .map((f) => fields.push(buildFieldContext(f, this.objType))) return { fields, } @@ -210,6 +219,7 @@ export class ModelRenderer extends AbstractRenderer { ...this.withFieldResolvers(), ...utils.withNames(this.objType), ...this.withVariantNames(), + ...this.withInterfaceRelationOptions(), } } } diff --git a/packages/hydra-cli/src/generate/field-context.ts b/packages/hydra-cli/src/generate/field-context.ts index 7b7cf044a..4583ed5ab 100644 --- a/packages/hydra-cli/src/generate/field-context.ts +++ b/packages/hydra-cli/src/generate/field-context.ts @@ -78,15 +78,22 @@ export function buildFieldContext( ): GeneratorContext { return { ...withFieldTypeGuardProps(f), - ...withRequired(f), - ...withUnique(f), ...withRelation(f), ...withArrayCustomFieldConfig(f), ...withTsTypeAndDecorator(f), ...withDerivedNames(f, entity), - ...withDescription(f), ...withTransformer(f), - ...withArrayProp(f), + ...withDecoratorOptions(f), + } +} + +export function withDecoratorOptions(f: Field): GeneratorContext { + return { + required: !f.nullable, + description: f.description, + unique: f.unique, + array: f.isList, + apiOnly: f.apiOnly, } } @@ -103,24 +110,6 @@ export function withFieldTypeGuardProps(f: Field): GeneratorContext { } } -export function withRequired(f: Field): GeneratorContext { - return { - required: !f.nullable, - } -} - -export function withDescription(f: Field): GeneratorContext { - return { - description: f.description, - } -} - -export function withUnique(f: Field): GeneratorContext { - return { - unique: f.unique, - } -} - export function withTsTypeAndDecorator(f: Field): GeneratorContext { const fieldType = f.columnType() if (TYPE_FIELDS[fieldType]) { @@ -182,12 +171,6 @@ 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/generate/utils.ts b/packages/hydra-cli/src/generate/utils.ts index 00b5cfa9a..9b6867ddf 100644 --- a/packages/hydra-cli/src/generate/utils.ts +++ b/packages/hydra-cli/src/generate/utils.ts @@ -2,6 +2,8 @@ import _, { upperFirst, kebabCase, camelCase, snakeCase, toLower } from 'lodash' import { GeneratorContext } from './SourcesGenerator' import { ObjectType, Field } from '../model' import pluralize from 'pluralize' +import { ModelType } from '../model/WarthogModel' +import { GraphQLEnumType, GraphQLEnumValueConfigMap } from 'graphql' export { upperFirst, kebabCase, camelCase } /* eslint-disable @typescript-eslint/no-unsafe-member-access */ @@ -28,6 +30,7 @@ export function names(name: string): { [key: string]: string } { typeormAliasName: toLower(name), // FIXME: do we have to support other namings? kebabName: kebabCase(name), relClassName: pascalCase(name), + aliasName: toLower(name), relCamelName: camelCase(name), // Not proper pluralization, but good enough and easy to fix in generated code camelNamePlural: camelPlural(name), @@ -66,6 +69,14 @@ export function ownFields(o: ObjectType): Field[] { return fields } +export function interfaceRelations(o: ObjectType): { fieldName: string }[] { + return o.fields + .filter((f) => f.isEntity()) + .map((f) => { + return { fieldName: camelCase(f.name) } + }) +} + export function generateJoinColumnName(name: string): string { return snakeCase(name.concat('_id')) } @@ -98,5 +109,40 @@ export function generateResolverReturnType( * @returns the same string with all whitecharacters removed */ export function compact(s: string): string { - return s.replace(/\s/g, '') + return s.replace(/\s+/g, ' ') +} + +/** + * Generate EnumField for interface filtering; filter interface by implementers + * e.g where: {type_in: [Type1, Type2]} + */ +export function generateEnumField(typeName: string, apiOnly = true): Field { + const enumField = new Field(`type`, typeName) + enumField.modelType = ModelType.ENUM + enumField.description = 'Filtering options for interface implementers' + enumField.isBuildinType = false + enumField.apiOnly = apiOnly + return enumField +} + +export function generateGraphqlEnumType( + name: string, + values: GraphQLEnumValueConfigMap +): GraphQLEnumType { + return new GraphQLEnumType({ + name, + values, + }) +} + +export function generateEnumOptions( + options: string[] +): GraphQLEnumValueConfigMap { + // const values: GraphQLEnumValueConfigMap = this._model + // .getSubclasses(i.name) + + return options.reduce((init, option) => { + init[option] = { value: option } + return init + }, {}) } diff --git a/packages/hydra-cli/src/model/Field.ts b/packages/hydra-cli/src/model/Field.ts index 02f1f6d99..6a4e2648c 100644 --- a/packages/hydra-cli/src/model/Field.ts +++ b/packages/hydra-cli/src/model/Field.ts @@ -33,6 +33,8 @@ export class Field { derivedFrom?: DerivedFrom + apiOnly?: boolean + constructor( name: string, type: string, diff --git a/packages/hydra-cli/src/model/ObjectType.ts b/packages/hydra-cli/src/model/ObjectType.ts index 9421653cc..1e5f3b93c 100644 --- a/packages/hydra-cli/src/model/ObjectType.ts +++ b/packages/hydra-cli/src/model/ObjectType.ts @@ -12,4 +12,5 @@ export interface ObjectType { description?: string isInterface?: boolean interfaces?: ObjectType[] // interface names + implementers?: string[] // List of interface implementer names } diff --git a/packages/hydra-cli/src/model/relations.ts b/packages/hydra-cli/src/model/relations.ts index b79c5cffc..c6d045faa 100644 --- a/packages/hydra-cli/src/model/relations.ts +++ b/packages/hydra-cli/src/model/relations.ts @@ -6,6 +6,7 @@ import { RelationType, } from '.' import { + camelCase, generateJoinColumnName, generateJoinTableName, } from '../generate/utils' @@ -77,7 +78,7 @@ function addOne2One(rel: EntityRelationship): void { // Typeorm requires to have ManyToOne field on the related object if the relation is OneToMany function createAdditionalField(entity: ObjectType, field: Field): Field { const f = new Field( - entity.name.toLowerCase() + field.name, + camelCase(entity.name.toLowerCase() + field.name), entity.name, true, false, diff --git a/packages/hydra-cli/src/parse/WarthogModelBuilder.ts b/packages/hydra-cli/src/parse/WarthogModelBuilder.ts index 638db1b91..029ec87ef 100644 --- a/packages/hydra-cli/src/parse/WarthogModelBuilder.ts +++ b/packages/hydra-cli/src/parse/WarthogModelBuilder.ts @@ -18,6 +18,11 @@ import { FTSDirective, FULL_TEXT_SEARCHABLE_DIRECTIVE } from './FTSDirective' import { availableTypes } from '../model/ScalarTypes' import * as DerivedFrom from './DerivedFromDirective' import { RelationshipGenerator } from '../generate/RelationshipGenerator' +import { + generateEnumField, + generateEnumOptions, + generateGraphqlEnumType, +} from '../generate/utils' const debug = Debug('qnode-cli:model-generator') @@ -122,6 +127,7 @@ export class WarthogModelBuilder { isInterface: o.kind === 'InterfaceTypeDefinition', interfaces: o.kind === 'ObjectTypeDefinition' ? this.getInterfaces(o) : [], + implementers: [], } as ObjectType } @@ -180,6 +186,10 @@ export class WarthogModelBuilder { }) debug(`Read and parsed fields: ${JSON.stringify(fields, null, 2)}`) + if (o.kind === 'InterfaceTypeDefinition') { + fields.push(generateEnumField(`${o.name.value}TypeOptions`)) + } + // ---Temporary Solution--- // Warthog's BaseModel already has `id` member so we remove id field from object // before generation models @@ -248,6 +258,23 @@ export class WarthogModelBuilder { } } + /** + * It generate enums for interfaces defined in the schema to support type base filtering for + * interface types. + */ + generateEnumsForInterface(): void { + this._model.interfaces.map(({ name }) => { + this._model.addEnum( + generateGraphqlEnumType( + `${name}TypeOptions`, + generateEnumOptions( + this._model.getSubclasses(name).map(({ name }) => name) + ) + ) + ) + }) + } + buildWarthogModel(): WarthogModel { this._model = new WarthogModel() @@ -262,6 +289,8 @@ export class WarthogModelBuilder { DerivedFrom.validateDerivedFields(this._model) new RelationshipGenerator(this._model).generate() + this.generateEnumsForInterface() + return this._model } } diff --git a/packages/hydra-cli/src/templates/entities/model.ts.mst b/packages/hydra-cli/src/templates/entities/model.ts.mst index 27f642610..418446673 100644 --- a/packages/hydra-cli/src/templates/entities/model.ts.mst +++ b/packages/hydra-cli/src/templates/entities/model.ts.mst @@ -52,8 +52,7 @@ import { InterfaceType } from 'type-graphql'; {{#isInterface}} @InterfaceType({{#description}} { description: `{{{description}}}` } {{/description}}) {{/isInterface}} -export {{#isInterface}}abstract{{/isInterface}} class {{className}} - extends {{^interfaces}}BaseModel{{/interfaces}} {{#interfaces}} {{className}} {{/interfaces}} { +export class {{className}} {{^isInterface}}extends BaseModel{{/isInterface}} { {{#fields}} {{#is.otm}} @@ -144,7 +143,8 @@ export {{#isInterface}}abstract{{/isInterface}} class {{className}} {{#is.enum}} @EnumField('{{tsType}}', {{tsType}}, { {{^required}}nullable: true,{{/required}} - {{#description}}description: `{{{description}}}`{{/description}} }) + {{#description}}description: `{{{description}}}`{{/description}} + {{#apiOnly}}, apiOnly: true {{/apiOnly}} }) {{camelName}}{{^required}}?{{/required}}{{#required}}!{{/required}}:{{tsType}} {{/is.enum}} @@ -160,10 +160,10 @@ export {{#isInterface}}abstract{{/isInterface}} class {{className}} {{/fields}} -{{^interfaces}} +{{^isInterface}} constructor(init?: Partial<{{className}}>) { super(); Object.assign(this, init); } -{{/interfaces}} +{{/isInterface}} } diff --git a/packages/hydra-cli/src/templates/graphql-server/src/WarthogBaseService.ts.mst b/packages/hydra-cli/src/templates/graphql-server/src/WarthogBaseService.ts.mst index e99ef7936..da2fcd508 100644 --- a/packages/hydra-cli/src/templates/graphql-server/src/WarthogBaseService.ts.mst +++ b/packages/hydra-cli/src/templates/graphql-server/src/WarthogBaseService.ts.mst @@ -16,7 +16,8 @@ export class WarthogBaseService extends BaseService { orderBy?: string | string[], pageOptions?: LimitOffset, fields?: string[], - paramKeyPrefix: string = 'param' + paramKeyPrefix: string = 'param', + aliases: (field: string) => string | undefined = () => undefined ): SelectQueryBuilder { const DEFAULT_LIMIT = 50; let qb = this.manager.createQueryBuilder(this.entityClass, this.klass); @@ -39,24 +40,13 @@ export class WarthogBaseService extends BaseService { } // Querybuilder requires you to prefix all fields with the table alias. It also requires you to // specify the field name using it's TypeORM attribute name, not the camel-cased DB column name - const selection = fields.map((field) => `${this.klass}.${field}`); - qb = qb.select(selection); + qb = qb.select(`${this.klass}.id`, aliases('id')); + fields.forEach( + (field) => field !== 'id' && qb.addSelect(`${this.klass}.${field}`, aliases(field)) + ); } - if (orderBy) { - if (!Array.isArray(orderBy)) { - orderBy = [orderBy]; - } - - orderBy.forEach((orderByItem: string) => { - const parts = orderByItem.toString().split('_'); - // TODO: ensure attr is one of the properties on the model - const attr = parts[0]; - const direction: 'ASC' | 'DESC' = parts[1] as 'ASC' | 'DESC'; - - qb = qb.addOrderBy(this.attrToDBColumn(attr), direction); - }); - } + qb = addOrderBy(orderBy, qb, (attr) => this.attrToDBColumn(attr)); // Soft-deletes are filtered out by default, setting `deletedAt_all` is the only way to turn this off const hasDeletedAts = Object.keys(where).find((key) => key.indexOf('deletedAt_') === 0); @@ -169,3 +159,53 @@ export class WarthogBaseService extends BaseService { return qb; } } + +export function addOrderBy( + orderBy: string | string[] | undefined, + qb: SelectQueryBuilder, + attrToDBColumn: (attr: string) => string +): SelectQueryBuilder { + const [attrs, directions] = parseOrderBy(orderBy); + + if (attrs.length !== directions.length) { + throw new Error('Number of attributes and sorting directions must match'); + } + + attrs.forEach((attr: string, index: number) => { + qb = qb.addOrderBy(attrToDBColumn(attr), directions[index].toUpperCase() as 'ASC' | 'DESC'); + }); + return qb; +} + +export function parseOrderBy( + orderBy: string | string[] | undefined +): [string[], ('asc' | 'desc')[]] { + const attrs: string[] = []; + const directions: ('asc' | 'desc')[] = []; + if (orderBy) { + if (!Array.isArray(orderBy)) { + orderBy = [orderBy]; + } + + orderBy.forEach((orderByItem: string) => { + const parts = orderByItem.toString().split('_'); + // TODO: ensure attr is one of the properties on the model + const attr = parts[0]; + const direction: 'asc' | 'desc' = parts[1].toLowerCase() as 'asc' | 'desc'; + + attrs.push(attr); + directions.push(direction); + }); + } + return [attrs, directions]; +} + +export function orderByFields(orderBy: string | string[] | undefined): string[] { + if (orderBy === undefined) { + return []; + } + if (!Array.isArray(orderBy)) { + orderBy = [(orderBy as unknown) as string]; + } + return orderBy.map((o) => o.toString().split('_')[0]); +} diff --git a/packages/hydra-cli/src/templates/graphql-server/src/index.ts.mst b/packages/hydra-cli/src/templates/graphql-server/src/index.ts.mst index 52928b0fa..ed6587bc5 100644 --- a/packages/hydra-cli/src/templates/graphql-server/src/index.ts.mst +++ b/packages/hydra-cli/src/templates/graphql-server/src/index.ts.mst @@ -22,7 +22,7 @@ class CustomNamingStrategy extends SnakeNamingStrategy { async function bootstrap() { loadConfig(); - const server = getServer({}, { namingStrategy: new CustomNamingStrategy(), maxQueryExecutionTime: 1000, logging: ["error"] }); + const server = getServer({}, { namingStrategy: new CustomNamingStrategy(), maxQueryExecutionTime: 1000, logging: [ process.env.WARTHOG_DB_LOGGING || "error"] }); // Create database tables. Warthog migrate command does not support CustomNamingStrategy thats why // we have this code diff --git a/packages/hydra-cli/src/templates/interfaces/resolver.ts.mst b/packages/hydra-cli/src/templates/interfaces/resolver.ts.mst index e57a88672..c9f0a675b 100644 --- a/packages/hydra-cli/src/templates/interfaces/resolver.ts.mst +++ b/packages/hydra-cli/src/templates/interfaces/resolver.ts.mst @@ -1,6 +1,7 @@ -import { Arg, Args, Mutation, Query, Resolver } from 'type-graphql'; +import { Arg, Args, Mutation, Query, Resolver, Info } from 'type-graphql'; import { Inject } from 'typedi'; import { Fields, StandardDeleteResponse, UserId } from 'warthog'; +import { GraphQLResolveInfo } from 'graphql'; import { {{className}}CreateInput, @@ -21,9 +22,10 @@ export class {{className}}Resolver { @Query(() => [{{className}}]) async {{camelNamePlural}}( @Args() { where, orderBy, limit, offset }: {{className}}WhereArgs, - @Fields() fields: string[] + @Fields() fields: string[], + @Info() info?: GraphQLResolveInfo | string ): Promise<{{className}}[]> { - return this.service.find<{{className}}WhereInput>(where, orderBy, limit, offset, fields); + return this.service.find<{{className}}WhereInput>(where, orderBy, limit, offset, fields, info); } diff --git a/packages/hydra-cli/src/templates/interfaces/service.ts.mst b/packages/hydra-cli/src/templates/interfaces/service.ts.mst index a133fdbca..4a33d6175 100644 --- a/packages/hydra-cli/src/templates/interfaces/service.ts.mst +++ b/packages/hydra-cli/src/templates/interfaces/service.ts.mst @@ -1,70 +1,126 @@ -import { Service } from 'typedi'; -import { getConnection } from 'typeorm'; -import { BaseService, WhereInput } from 'warthog'; -import { isArray, orderBy } from 'lodash'; +import { Service, Inject } from 'typedi'; +import { getManager, SelectQueryBuilder, ObjectLiteral } from 'typeorm'; +import { BaseModel, WhereInput } from 'warthog'; +import { snakeCase, camelCase, uniq, orderBy } from 'lodash'; +import { GraphQLResolveInfo } from 'graphql'; {{#subclasses}} import { {{className}}Service } from '../{{kebabName}}/{{kebabName}}.service' +import { {{className}} } from '../{{kebabName}}/{{kebabName}}.model'; {{/subclasses}} -import { Inject } from 'typedi'; -import _ from 'lodash'; import { {{className}} } from './{{kebabName}}.model'; +import { {{className}}TypeOptions } from '../enums/enums'; + +import { + WarthogBaseService, + addOrderBy, + orderByFields, + parseOrderBy, +} from '../../WarthogBaseService'; + @Service('{{className}}Service') export class {{className}}Service { + public readonly typeToService: { [key: string]: WarthogBaseService } = {}; + constructor( {{#subclasses}} @Inject('{{className}}Service') public readonly {{camelName}}Service: {{className}}Service, {{/subclasses}} - ) {} + ) { + {{#subclasses}} + this.typeToService['{{className}}'] = {{camelName}}Service; + {{/subclasses}} + } - async find(where?: any, ob?: string | string[], limit?: number, offset?: number, fields?: string[]): Promise<{{className}}[]> { - let _limit = limit || 50; - let _offset = offset || 0; + async find( + where?: any, + ob?: string | string[], + _limit?: number, + _offset?: number, + _fields?: string[], + info?: GraphQLResolveInfo | string + ): Promise { + const limit = _limit ?? 50; + const offset = _offset ?? 0; + const fields = uniq([...(_fields || []), ...orderByFields(ob)]); - if (_limit + _offset > 1000) { - throw new Error('Limit or offset are too large'); - } + if (limit > 10000) { + throw new Error('Cannot fetch more than 10000 at a time'); + } - const connection = getConnection(); - const queryRunner = connection.createQueryRunner(); - // establish real database connection using our new query runner - await queryRunner.connect(); - await queryRunner.startTransaction('REPEATABLE READ'); + const { type_in, type_eq } = where; + const types: string[] = (type_eq + ? [type_eq] + : type_in || [ {{#subclasses}} {{className}}, {{/subclasses}} ] + ).map((t: EventTypeOptions) => t.toString()); - {{#subclasses}} - let {{camelNamePlural}} = []; - {{/subclasses}} - try { - // fetching all the fields to allow type-dependent field resolutions - {{#subclasses}} - {{camelNamePlural}} = await this.{{camelName}}Service.find(where, ob, _limit + _offset, 0); - {{/subclasses}} - } finally { - await queryRunner.commitTransaction(); - } - - let collect: any[] = [{{#subclasses}}...{{camelNamePlural}}, {{/subclasses}}]; - if (ob) { - const directions: ('asc' | 'desc')[] = [] - const attrs: string[] = [] - if (!isArray(ob)) { - ob = [ob] - } - ob.map(o => { - // NB: copied from warthog's BaseService - const parts = o.toString().split('_'); - const direction: 'asc' | 'desc' = parts[1] as 'asc' | 'desc'; - directions.push(direction) - attrs.push(parts[0]) - }) - collect = orderBy(collect, attrs, directions); - } - - const _end = Math.min(collect.length, _limit + _offset); - _offset = Math.min(collect.length, _offset); - - return collect.slice(_offset, _end); + delete where.type_in; + delete where.type_eq; + // take fields that are present in all implemetations + const commonFields = fields.filter((f) => + types.reduce( + (hasField: boolean, t) => this.typeToService[t].columnMap[f] !== undefined && hasField, + true + ) + ); + + const queries: SelectQueryBuilder[] = types.map( + (t) => + (this.typeToService[t] + .buildFindQueryWithParams( + where, + undefined, + undefined, + commonFields, + camelCase(t), + (field) => snakeCase(field) + ) + .addSelect(`'${t}'`, 'type') + .take(undefined) as unknown) as SelectQueryBuilder + ); + + const parameters = queries.reduce((params: ObjectLiteral, q: SelectQueryBuilder) => { + return { ...params, ...q.getParameters() }; + }, {} as ObjectLiteral); + + let rawQuery = queries.map((q) => `( ${q.getQuery()} )`).join(' UNION ALL '); + + let qb = getManager() + .createQueryBuilder() + .select('u.id', 'u_id') + .addSelect('u.type', 'u_type') + .from(`( ${rawQuery} )`, 'u') + .setParameters(parameters) + .take(limit) + .skip(offset); + + qb = (addOrderBy( + ob, + qb, + (attr) => `u.${snakeCase(attr)}` + ) as unknown) as SelectQueryBuilder; + + const results = await qb.getRawMany<{ u_id: string; u_type: string }>(); + + const entityPromises: Promise[] = types.map((t) => { + const service = this.typeToService[t]; + return service.find( + { id_in: results.filter((r) => r.u_type === t).map((r) => r.u_id) }, + undefined, + limit, + undefined, + fields.filter((f) => service.columnMap[f] !== undefined) + ); + }); + + const result = (await Promise.all(entityPromises)).reduce( + (acc, curr) => [...acc, ...curr], + [] as Event[] + ); + const [attrs, dirs] = parseOrderBy(ob); + + return orderBy(result, attrs, dirs); } -} \ No newline at end of file +} diff --git a/packages/hydra-cli/test/fixtures/interfaces.graphql b/packages/hydra-cli/test/fixtures/interfaces.graphql new file mode 100644 index 000000000..4ab8ce6dd --- /dev/null +++ b/packages/hydra-cli/test/fixtures/interfaces.graphql @@ -0,0 +1,17 @@ +type Event @entity { + id: ID! + inBlock: Int! +} + +interface MembershipEvent @entity { + event: Event! +} + +type MembershipBoughtEvent implements MembershipEvent @entity { + event: Event! + handle: String! +} + +type MembershipInvitedEvent implements MembershipEvent @entity { + event: Event! +} diff --git a/packages/hydra-cli/test/helpers/Interfaces.test.ts b/packages/hydra-cli/test/helpers/Interfaces.test.ts new file mode 100644 index 000000000..857ac1830 --- /dev/null +++ b/packages/hydra-cli/test/helpers/Interfaces.test.ts @@ -0,0 +1,37 @@ +import { expect } from 'chai' +import * as fs from 'fs-extra' +import { WarthogModelBuilder } from '../../src/parse/WarthogModelBuilder' +import { WarthogModel } from '../../src/model' +import { ModelRenderer } from '../../src/generate/ModelRenderer' + +describe('InterfaceRenderer', () => { + let generator: ModelRenderer + let warthogModel: WarthogModel + let modelTemplate: string + + before(() => { + // set timestamp in the context to make the output predictable + modelTemplate = fs.readFileSync( + './src/templates/entities/model.ts.mst', + 'utf-8' + ) + + warthogModel = new WarthogModelBuilder( + 'test/fixtures/interfaces.graphql' + ).buildWarthogModel() + + generator = new ModelRenderer( + warthogModel, + warthogModel.lookupInterface('MembershipEvent') + ) + }) + + it('should render interface with enum field', () => { + const rendered = generator.render(modelTemplate) + + expect(rendered).include( + `@EnumField('MembershipEventTypeOptions', MembershipEventTypeOptions,`, + 'shoud have an EnumField' + ) + }) +}) diff --git a/packages/hydra-cli/test/helpers/ModelRenderer.test.ts b/packages/hydra-cli/test/helpers/ModelRenderer.test.ts index 1997a16a3..2ec0d0146 100644 --- a/packages/hydra-cli/test/helpers/ModelRenderer.test.ts +++ b/packages/hydra-cli/test/helpers/ModelRenderer.test.ts @@ -346,7 +346,7 @@ describe('ModelRenderer', () => { ) }) - it('should extend interface type', function () { + it('Should render all fields in the interface implementations', () => { const model = fromStringSchema(` interface IEntity @entity { field1: String @@ -354,15 +354,30 @@ describe('ModelRenderer', () => { type A implements IEntity @entity { field1: String field2: String - }`) + }`) generator = new ModelRenderer(model, model.lookupEntity('A')) const rendered = generator.render(modelTemplate) - expect(rendered).to.include('extends IEntity') - expect(rendered).to.include( - `import { IEntity } from '../i-entity/i-entity.model'`, - 'should import interface type' - ) + expect(rendered).to.include('field1', 'should render both fields') + expect(rendered).to.include('field2', 'should render both fields') }) + // THIS IS NO LONGER THE CASE + // it('should extend interface type', function () { + // const model = fromStringSchema(` + // interface IEntity @entity { + // field1: String + // } + // type A implements IEntity @entity { + // field1: String + // field2: String + // }`) + // generator = new ModelRenderer(model, model.lookupEntity('A')) + // const rendered = generator.render(modelTemplate) + // expect(rendered).to.include('extends IEntity') + // expect(rendered).to.include( + // `import { IEntity } from '../i-entity/i-entity.model'`, + // 'should import interface type' + // ) + // }) it('should import two unions from variants', async function () { const model = fromStringSchema(` @@ -401,19 +416,20 @@ describe('ModelRenderer', () => { ) }) - it('should not include interface field', function () { - const model = fromStringSchema(` - interface IEntity @entity { - field1: String - } - type A implements IEntity @entity { - field1: String - field2: String - }`) - generator = new ModelRenderer(model, model.lookupEntity('A')) - const rendered = generator.render(modelTemplate) - expect(rendered).to.not.include('field1') - }) + // TODO: THIS TEST DOES NOT APPLY, we render all the interface fields + // it('should not include interface field', function () { + // const model = fromStringSchema(` + // interface IEntity @entity { + // field1: String + // } + // type A implements IEntity @entity { + // field1: String + // field2: String + // }`) + // generator = new ModelRenderer(model, model.lookupEntity('A')) + // const rendered = generator.render(modelTemplate) + // expect(rendered).to.not.include('field1') + // }) it('should render interface', function () { const model = fromStringSchema(` diff --git a/packages/hydra-cli/test/helpers/Relationships.test.ts b/packages/hydra-cli/test/helpers/Relationships.test.ts index fc1592c24..55f48b265 100644 --- a/packages/hydra-cli/test/helpers/Relationships.test.ts +++ b/packages/hydra-cli/test/helpers/Relationships.test.ts @@ -4,6 +4,7 @@ import { ModelRenderer } from '../../src/generate/ModelRenderer' import { RelationshipGenerator } from '../../src/generate/RelationshipGenerator' import { WarthogModel } from '../../src/model' import { fromStringSchema } from './model' +import { compact as c } from '../../src/generate/utils' describe('ReletionshipGenerator', () => { let model: WarthogModel @@ -79,4 +80,37 @@ describe('ReletionshipGenerator', () => { `async eventinExtrinsic(@Root() r: Extrinsic): Promise {` ) }) + + it('should handle names with interfaces and numbers', () => { + model = fromStringSchema(` + type Extrinsic @entity { + hash: String! + } + + interface ExtrinsicOnlyEvent @entity { + extrinsic: Extrinsic! + } + + type MembershipExtrinsicOnlyEvent1 implements ExtrinsicOnlyEvent @entity { + extrinsic: Extrinsic! + handle: String! + }`) + + generator = new ModelRenderer( + model, + model.lookupEntity('MembershipExtrinsicOnlyEvent1') + ) + const rendered = generator.render(modelTemplate) + + expect(c(rendered)).to.include( + c(` + @ManyToOne(() => Extrinsic, (param: Extrinsic) => param.membershipextrinsiconlyevent1Extrinsic, { + skipGraphQLField: true, + modelName: 'MembershipExtrinsicOnlyEvent1', + relModelName: 'Extrinsic', + propertyName: 'extrinsic', + }) + extrinsic!: Extrinsic`) + ) + }) }) diff --git a/packages/hydra-cli/test/helpers/WarthogModel.test.ts b/packages/hydra-cli/test/helpers/WarthogModel.test.ts index 5aac59ea1..49e6bc952 100644 --- a/packages/hydra-cli/test/helpers/WarthogModel.test.ts +++ b/packages/hydra-cli/test/helpers/WarthogModel.test.ts @@ -109,6 +109,18 @@ describe('WarthogModel', () => { ) }) + it('Should add all fields to interface implementations', () => { + const model = fromStringSchema(` + interface IEntity @entity { + field1: String + } + type A implements IEntity @entity { + field1: String + field2: String + }`) + expect(model.lookupEntity('A').fields).length(2, 'Should add both fields') + }) + it('Should lookup types', () => { const model = fromStringSchema(` union Poor = HappyPoor | Miserable diff --git a/packages/hydra-cli/test/helpers/model-index-context.test.ts b/packages/hydra-cli/test/helpers/model-index-context.test.ts index 3b8f6b277..cdef1aaa1 100644 --- a/packages/hydra-cli/test/helpers/model-index-context.test.ts +++ b/packages/hydra-cli/test/helpers/model-index-context.test.ts @@ -62,7 +62,7 @@ describe('model index render', () => { 'should import entities' ) expect(c(rendered)).to.include( - c(`export { MyEntity } `), + c(`export { MyEntity }`), 'should export entities' ) expect(c(rendered)).to.include( @@ -72,7 +72,7 @@ describe('model index render', () => { 'should import interfaces' ) expect(c(rendered)).to.include( - c(`export { MyInterface } `), + c(`export { MyInterface }`), 'should export interfaces' ) }) diff --git a/packages/hydra-e2e-tests/fixtures/manifest.yml b/packages/hydra-e2e-tests/fixtures/manifest.yml index 9d31c81cb..fb4e469e2 100644 --- a/packages/hydra-e2e-tests/fixtures/manifest.yml +++ b/packages/hydra-e2e-tests/fixtures/manifest.yml @@ -29,6 +29,9 @@ mappings: - handler: preHook filter: height: '[0,0]' + - handler: loader + filter: + height: '[0,0]' - handler: preHook filter: height: '[1, 2]' diff --git a/packages/hydra-e2e-tests/fixtures/mappings/index.ts b/packages/hydra-e2e-tests/fixtures/mappings/index.ts index e27b60abb..4e258f200 100644 --- a/packages/hydra-e2e-tests/fixtures/mappings/index.ts +++ b/packages/hydra-e2e-tests/fixtures/mappings/index.ts @@ -2,3 +2,5 @@ // so that the indexer picks them up // export { balancesTransfer as balances_Transfer } from './transfer' export { balancesTransfer, timestampCall, preHook, postHook } from './mappings' + +export { loader } from './loaders' diff --git a/packages/hydra-e2e-tests/fixtures/mappings/loaders.ts b/packages/hydra-e2e-tests/fixtures/mappings/loaders.ts new file mode 100644 index 000000000..867e4edaf --- /dev/null +++ b/packages/hydra-e2e-tests/fixtures/mappings/loaders.ts @@ -0,0 +1,46 @@ +import { + Event, + EventA, + EventB, + EventC, + Network, +} from '../generated/graphql-server/model' + +// run 'NODE_URL= EVENTS= yarn codegen:mappings-types' +// to genenerate typescript classes for events, such as Balances.TransferEvent +import { BlockContext, StoreContext } from '@dzlzv/hydra-common' + +// run before genesis +export async function loader({ store }: BlockContext & StoreContext) { + console.log(`Loading events`) + const a = new EventA({ + inExtrinsic: 'test', + inBlock: 0, + network: Network.ALEXANDRIA, + indexInBlock: 0, + field1: 'field1', + }) + + const b = new EventB({ + inExtrinsic: 'test', + inBlock: 0, + network: Network.BABYLON, + indexInBlock: 1, + field2: 'field2', + }) + + const c = new EventC({ + inExtrinsic: 'test', + inBlock: 0, + network: Network.OLYMPIA, + indexInBlock: 2, + field3: 'field3', + }) + + await Promise.all([ + store.save(a), + store.save(b), + store.save(c), + ]) + console.log(`Loaded events`) +} diff --git a/packages/hydra-e2e-tests/fixtures/schema.graphql b/packages/hydra-e2e-tests/fixtures/schema.graphql index 7ea784a24..ccf45d27c 100644 --- a/packages/hydra-e2e-tests/fixtures/schema.graphql +++ b/packages/hydra-e2e-tests/fixtures/schema.graphql @@ -57,19 +57,49 @@ type MiddleClass @variant { union Status = MiddleClass | HappyPoor | Miserable -############## Test purpose only @hydra-e2e-tests ################# -# To make sure graphql api is generated as expected + type Extrinsic @entity { id: ID! hash: String! } + interface Event @entity { indexInBlock: Int! - inExtrinsic: Extrinsic + inExtrinsic: String + inBlock: Int! + network: Network! } -type BoughtMemberEvent implements Event @entity { + +type EventA implements Event @entity { id: ID! + inExtrinsic: String + inBlock: Int! + network: Network! indexInBlock: Int! - inExtrinsic: Extrinsic + field1: String! } -################################################### + +type EventB implements Event @entity { + id: ID! + inExtrinsic: String + inBlock: Int! + network: Network! + indexInBlock: Int! + field2: String! +} + +type EventC implements Event @entity { + id: ID! + inExtrinsic: String + inBlock: Int! + network: Network! + indexInBlock: Int! + field3: String! +} + +enum Network { + BABYLON + ALEXANDRIA + ROME + OLYMPIA +} \ No newline at end of file diff --git a/packages/hydra-e2e-tests/run-tests.sh b/packages/hydra-e2e-tests/run-tests.sh index dd21703db..a48b2ecd1 100644 --- a/packages/hydra-e2e-tests/run-tests.sh +++ b/packages/hydra-e2e-tests/run-tests.sh @@ -26,7 +26,7 @@ docker-compose up -d # wait for the processor to start grinding attempt_counter=0 -max_attempts=20 +max_attempts=25 until $(curl -s --head --request GET http://localhost:3000/metrics/hydra_processor_last_scanned_block | grep "200" > /dev/null); do if [ ${attempt_counter} -eq ${max_attempts} ];then diff --git a/packages/hydra-e2e-tests/test/e2e/api/graphql-queries.ts b/packages/hydra-e2e-tests/test/e2e/api/graphql-queries.ts index 4c27e7efb..10f9819a6 100644 --- a/packages/hydra-e2e-tests/test/e2e/api/graphql-queries.ts +++ b/packages/hydra-e2e-tests/test/e2e/api/graphql-queries.ts @@ -85,19 +85,19 @@ query { } ` -export const INTERFACE_TYPES_WITH_RELATIONSHIP = gql` - query InterfaceQuery { - events { - indexInBlock - ... on BoughtMemberEvent { - inExtrinsic { - id - hash - } - } - } - } -` +// export const INTERFACE_TYPES_WITH_RELATIONSHIP = gql` +// query InterfaceQuery { +// events { +// indexInBlock +// ... on BoughtMemberEvent { +// inExtrinsic { +// id +// hash +// } +// } +// } +// } +// ` export const PROCESSOR_SUBSCRIPTION = gql` subscription { @@ -141,3 +141,33 @@ export const VARIANT_FILTER_MISREABLE_ACCOUNTS = gql` } } ` + +export const EVENT_INTERFACE_QUERY = gql` + query { + events( + where: { inBlock_lt: 2, type_in: [EventA, EventB, EventC] } + orderBy: [indexInBlock_DESC, network_DESC] + ) { + indexInBlock + inExtrinsic + network + ... on EventA { + field1 + } + ... on EventB { + field2 + } + ... on EventC { + field3 + } + } + } +` + +export const INTERFACES_FILTERING_BY_ENUM = gql` + query InterfaceQuery { + events(where: { type_in: [EventA] }) { + indexInBlock + } + } +` diff --git a/packages/hydra-e2e-tests/test/e2e/api/processor-api.ts b/packages/hydra-e2e-tests/test/e2e/api/processor-api.ts index ad6573ec4..eecaab4fa 100644 --- a/packages/hydra-e2e-tests/test/e2e/api/processor-api.ts +++ b/packages/hydra-e2e-tests/test/e2e/api/processor-api.ts @@ -8,8 +8,8 @@ import { FETCH_INSERTED_AT_FIELD_FROM_TRANSFER, FTS_COMMENT_QUERY_WITH_WHERE_CONDITION, LAST_BLOCK_TIMESTAMP, - INTERFACE_TYPES_WITH_RELATIONSHIP, PROCESSOR_SUBSCRIPTION, + INTERFACES_FILTERING_BY_ENUM, } from './graphql-queries' import { SubscriptionClient } from 'graphql-subscriptions-client' import pWaitFor = require('p-wait-for') @@ -140,10 +140,8 @@ export async function getProcessorStatus(): Promise { return processorStatus as ProcessorStatus } -export async function queryInterface(): Promise<{ events: [] }> { - return await getGQLClient().request<{ - events: [] - }>(INTERFACE_TYPES_WITH_RELATIONSHIP) +export async function queryInterfacesByEnum(): Promise<{ events: [] }> { + return getGQLClient().request<{ events: [] }>(INTERFACES_FILTERING_BY_ENUM) } export async function accountByOutgoingTxValue( diff --git a/packages/hydra-e2e-tests/test/e2e/interfaces-e2e.test.ts b/packages/hydra-e2e-tests/test/e2e/interfaces-e2e.test.ts new file mode 100644 index 000000000..12410f51e --- /dev/null +++ b/packages/hydra-e2e-tests/test/e2e/interfaces-e2e.test.ts @@ -0,0 +1,45 @@ +import pWaitFor from 'p-wait-for' +import { expect } from 'chai' +import { + getGQLClient, + getProcessorStatus, + queryInterfacesByEnum, +} from './api/processor-api' +import { EVENT_INTERFACE_QUERY } from './api/graphql-queries' + +describe('end-to-end interfaces tests', () => { + before(async () => { + // wait until the indexer indexes the block and the processor picks it up + await pWaitFor( + async () => { + return (await getProcessorStatus()).lastCompleteBlock > 0 + }, + { interval: 50 } + ) + }) + + it('executes a flat interface query with fragments', async () => { + const result = await getGQLClient().request<{ + events: any[] + }>(EVENT_INTERFACE_QUERY) + + expect(result.events.length).to.be.equal(3, 'should find three events') + expect(result.events[0].field3).to.be.equal( + 'field3', + 'should return eventC with field3' + ) + expect(result.events[1].field2).to.be.equal( + 'field2', + 'should return eventB with field2' + ) + expect(result.events[2].field1).to.be.equal( + 'field1', + 'should return eventA with field1' + ) + }) + + it('perform filtering on interfaces by implementers enum types', async () => { + const { events } = await queryInterfacesByEnum() + expect(events.length).to.be.equal(1, 'shoud find an interface by type') + }) +}) diff --git a/packages/hydra-e2e-tests/test/e2e/transfer-e2e.test.ts b/packages/hydra-e2e-tests/test/e2e/transfer-e2e.test.ts index c448b124e..89ea686eb 100644 --- a/packages/hydra-e2e-tests/test/e2e/transfer-e2e.test.ts +++ b/packages/hydra-e2e-tests/test/e2e/transfer-e2e.test.ts @@ -5,7 +5,6 @@ import { findTransfersByComment, findTransfersByCommentAndWhereCondition, findTransfersByValue, - queryInterface, getProcessorStatus, accountByOutgoingTxValue, getGQLClient, @@ -104,10 +103,47 @@ describe('end-to-end transfer tests', () => { ) }) - it('performs query on interface types, expect implementer to hold relationship', async () => { - const { events } = await queryInterface() - // We don't expect any data only testing Graphql API types - expect(events.length).to.be.equal(0, 'should not find any event.') + it('founds an account by incoming tx value (some)', async () => { + let accs = await accountByOutgoingTxValue( + ACCOUNTS_BY_VALUE_GT_SOME, + BigInt(300000) + ) + expect(accs.length).to.be.equal(0, 'some tx vals > 300000: false') + + accs = await accountByOutgoingTxValue( + ACCOUNTS_BY_VALUE_GT_SOME, + BigInt(200000) + ) + expect(accs.length).to.be.equal(1, 'some tx vals > 200000: true') + }) + + it('founds an account by incoming tx value (none)', async () => { + let accs = await accountByOutgoingTxValue( + ACCOUNTS_BY_VALUE_GT_NONE, + BigInt(300000) + ) + expect(accs.length).to.be.equal(2, 'none tx vals > 300000: true') // BOTH BOB AND ALICE + + accs = await accountByOutgoingTxValue( + ACCOUNTS_BY_VALUE_GT_NONE, + BigInt(200000) + ) + expect(accs.length).to.be.equal(1, 'none tx vals > 200000: false') // ONLY BOB, it has no outgoing txs + }) + + it('founds an account by incoming tx value (every)', async () => { + let accs = await accountByOutgoingTxValue( + ACCOUNTS_BY_VALUE_GT_EVERY, + BigInt(txAmount2) // since the value filter is gt, + // the second transfer does not satisfy the condition + ) + expect(accs.length).to.be.equal(0, 'every tx val > 1000: false') + + accs = await accountByOutgoingTxValue( + ACCOUNTS_BY_VALUE_GT_EVERY, + BigInt(20) + ) + expect(accs.length).to.be.equal(1, 'every tx val > 20: true') }) it('founds an account by incoming tx value (some)', async () => { diff --git a/packages/hydra-typegen/package.json b/packages/hydra-typegen/package.json index 8a235a47c..216d96452 100644 --- a/packages/hydra-typegen/package.json +++ b/packages/hydra-typegen/package.json @@ -23,8 +23,7 @@ "postpack": "rm -f oclif.manifest.json", "lint": "eslint . --cache --ext .ts", "prepack": "yarn build && oclif-dev manifest", - "test": "nyc --extension .ts mocha --require ts-node/register --forbid-only \"./{src,test}/**/*.spec.ts\"", - "version": "oclif-dev readme && git add README.md" + "test": "nyc --extension .ts mocha --require ts-node/register --forbid-only \"./{src,test}/**/*.spec.ts\"" }, "oclif": { "commands": "./lib/commands",