diff --git a/CHANGELOG.md b/CHANGELOG.md index c83ffd6..3a03802 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,4 +12,7 @@ ## 0.5.1 - add @defaultValue decorator + +## 0.5.5 +- add support for inheritance diff --git a/README.md b/README.md index dffc78a..bed91c8 100644 --- a/README.md +++ b/README.md @@ -414,3 +414,52 @@ type AudioAsset implements Asset { length: Int } ``` + +### Inheritance + +```typescript +import {field, id, interfaceType, nonNull, type} from "gql-schema"; +import {GraphQLInt, GraphQLString} from "graphql"; + +@type() +class PersistedObject { + @id() @nonNull() + id:string; + + @field(GraphQLInt) + createdAt:number; //for simplification store as integer timestamp + + @field(GraphQLInt) + updatedAt:number; //for simplification store as integer timestamp +} + +@type() +class User extends PersistedObject { + @field(GraphQLString) + email:string +} + +@type() +class Product extends PersistedObject { + @field(GraphQLString) + productName:string +} +``` + +Given annotated classes will produce + +```graphql +type User { + id: ID! + createdAt: Int + updatedAt: Int + email: String +} + +type Product { + id: ID! + createdAt: Int + updatedAt: Int + productName: String +} +``` \ No newline at end of file diff --git a/ROADMAP.md b/ROADMAP.md index 245bd91..3aaaa3c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,34 +1,5 @@ # ROADMAP -### Add support for inheritance -- investigate possible pitfalls - -```typescript -class PersistedObject { - @id() - id:string - - @field(CustomGraphQLDateScalar) - createdAt:Date - - @field(CustomGraphQLDateScalar) - updatedAt:Date -} - -@type() -class User extends PersistedObject {} -``` - -should generate: - -```graphql -type User { - id: ID - createdAt: CustomGraphQLDateScalar - updateAt: CustomGraphQLDateScalar -} -``` - ### Reorganize, rewrite specs ### Infer basic types from ts metadata @@ -39,6 +10,7 @@ type User { - add validation of field configuration - add more descriptive logs for all errors thrown from Metadata classes during native object creation - assert one metadata object is attached to class +- print warnings if some @decarotors are skipped (for example @defaultValue for types other than input) ### Refactoring - use one convention for private fields diff --git a/lib/fields-metadata/FieldsMetadata.ts b/lib/fields-metadata/FieldsMetadata.ts index 5455716..d108862 100644 --- a/lib/fields-metadata/FieldsMetadata.ts +++ b/lib/fields-metadata/FieldsMetadata.ts @@ -1,21 +1,51 @@ import 'reflect-metadata'; -import {metadataGet, metadataGetOrSet} from "../utils/metadataFactories"; +import {metadataGet} from "../utils/metadataFactories"; import {FieldConfig} from "./FieldConfig"; +import {getSuperClass} from "../utils/core"; const FIELDS_METADATA_KEY = '__FIELDS_METADATA_KEY'; export class FieldsMetadata { static getForClass = metadataGet(FIELDS_METADATA_KEY); - static getOrCreateForClass = metadataGetOrSet(FIELDS_METADATA_KEY, FieldsMetadata); + static getOrCreateForClass(klass):FieldsMetadata { + let attributesMetadata = Reflect.getOwnMetadata(FIELDS_METADATA_KEY, klass); + if (!attributesMetadata) { + attributesMetadata = new FieldsMetadata(); + Reflect.defineMetadata(FIELDS_METADATA_KEY, attributesMetadata, klass); - protected _fields:{ [fieldName:string]:FieldConfig } = {}; + + let superClass = getSuperClass(klass); + if (superClass) { + attributesMetadata.setParent(FieldsMetadata.getOrCreateForClass(superClass)); + } + } + + return attributesMetadata; + } + + + private parent:FieldsMetadata; + protected _ownFields:{ [fieldName:string]:FieldConfig } = {}; getField(fieldName:string):FieldConfig { - return this._fields[fieldName] || (this._fields[fieldName] = new FieldConfig()) + return this._ownFields[fieldName] || (this._ownFields[fieldName] = new FieldConfig()) } getFields():{ [fieldName:string]:FieldConfig } { - return this._fields; + let parentFields = this.parent ? this.parent.getFields() : {}; + + //TODO: consider deep merge of field configuration. In current implementation ownField configuration overrides completely field config from parent + return { + ...parentFields, + ...this._ownFields + } } -} \ No newline at end of file + + private setParent(fieldsMetadata:FieldsMetadata) { + this.parent = fieldsMetadata; + } +} + + + diff --git a/lib/fields-metadata/TypeValue.ts b/lib/fields-metadata/TypeValue.ts index 82a25b1..b28f478 100644 --- a/lib/fields-metadata/TypeValue.ts +++ b/lib/fields-metadata/TypeValue.ts @@ -5,6 +5,8 @@ export class TypeValue { private _type:T; private _typeThunk:Thunk; + //TODO: validate if both _type and _typeThunk are being set + setType(type:T) { this._type = type; } diff --git a/lib/utils/core.ts b/lib/utils/core.ts index 93264b2..a4e6dd0 100644 --- a/lib/utils/core.ts +++ b/lib/utils/core.ts @@ -25,6 +25,11 @@ export function invariant(condition:boolean, message:string) { } } +export function getSuperClass(klass) { + let superClass = Object.getPrototypeOf(klass); + return superClass.name ? superClass : null; +} + export function propagateErrorWithContext(ctx:string, fn:() => T):T { try { return fn(); diff --git a/spec/fields-metadata/FieldsMetadata.spec.ts b/spec/fields-metadata/FieldsMetadata.spec.ts new file mode 100644 index 0000000..c7275b1 --- /dev/null +++ b/spec/fields-metadata/FieldsMetadata.spec.ts @@ -0,0 +1,25 @@ +import {FieldsMetadata} from "../../lib/fields-metadata/FieldsMetadata"; +import {GraphQLString} from "graphql"; +import {expect} from 'chai' + +describe(`FieldsMetadata`, () => { + describe('inheritance', () => { + class ParentClass {} + + let fieldsMetadata = FieldsMetadata.getOrCreateForClass(ParentClass); + let parentField = fieldsMetadata.getField('parentField'); + parentField.setType(GraphQLString); + parentField.setDescription('Parent Field Description'); + + it('inherits all parent class properties', () => { + class ChildClass extends ParentClass {} + + let fieldsMetadata = FieldsMetadata.getOrCreateForClass(ChildClass); + let fields = fieldsMetadata.getFields(); + + expect(fields).to.have.keys(['parentField']); + }); + + //TODO: add more test cases + }) +}); \ No newline at end of file diff --git a/spec/integration/inheritance.spec.ts b/spec/integration/inheritance.spec.ts new file mode 100644 index 0000000..f04149e --- /dev/null +++ b/spec/integration/inheritance.spec.ts @@ -0,0 +1,164 @@ +import {buildASTSchema, GraphQLInt, GraphQLString, parse, printType} from "graphql"; +import {expect} from 'chai'; +import {GraphQLSchema} from "graphql/type/schema"; +import {createSchema, field, id, list, nonNull, nonNullItems, type} from "../../lib"; +import {FieldsMetadata} from "../../lib/fields-metadata/FieldsMetadata"; +import {ClassType} from "../../lib/utils/types"; + + +@type() +class GenericQueryListResult { + items:T; + + @field(GraphQLInt) @nonNull() + totalCount:number; +} + +//class factory +function ListQueryResult(itemClass:ClassType) { + let listResultsClass = class extends GenericQueryListResult {}; + let fields = FieldsMetadata.getOrCreateForClass(listResultsClass); + let itemsField = fields.getField('items'); + itemsField.setListType(itemClass); + itemsField.setNonNullConstraint(); + itemsField.setNonNullItemsConstraint(); + return listResultsClass; +} + +@type() +class Mutation { + @field(GraphQLString) + unused:string; +} + + +@type() +class ListResult { + @field(GraphQLInt) @nonNull() + totalCount:number; +} + +@type() +class PersistedObject { + @id() @nonNull() + id:string; + + @field(GraphQLInt) + createdAt:number; //for simplification store as integer timestamp instead of custom scalar + + @field(GraphQLInt) + updatedAt:number; //for simplification store as integer timestamp instead of custom scalar +} + +@type() +class User extends PersistedObject { + @field(GraphQLString) + email:string +} + +@type() +class UsersList extends ListQueryResult(User) {} + +@type() +class Product extends PersistedObject { + @field(GraphQLString) + productName:string +} + +@type() +class ProductsList extends ListResult { + @list(Product) @nonNull() @ nonNullItems() + products:Product[] +} + + +@type() +class Query { + @field(UsersList) @nonNull() + users:UsersList; + + @field(ProductsList) @nonNull() + products:ProductsList; +} + + +function createdSchemaFromDecoratedClasses():GraphQLSchema { + return createSchema(Query, Mutation); +} + +function createSchemaFromDefinition():GraphQLSchema { + const definition = ` + type User { + id: ID! + createdAt: Int + updatedAt: Int + email: String + } + + type Product { + id: ID! + createdAt: Int + updatedAt: Int + productName: String + } + + type UsersList { + totalCount: Int! + items: [User!]! + } + + type ProductsList { + totalCount: Int! + products: [Product!]! + } + + type Query { + users: UsersList! + products: ProductsList! + } + + type Mutation { + unused:String + } + `; + return buildASTSchema(parse(definition)); +} + + +function expectTypesEqual(typeName:string) { + expect(printType(createdSchemaFromDecoratedClasses().getType(typeName))) + .to.eql(printType(createSchemaFromDefinition().getType(typeName))); +} + +describe("building schema", () => { + + describe("type Query", () => { + it("generates proper type", () => { + expectTypesEqual('Query'); + }); + }); + + describe("type User", () => { + it("generates proper type", () => { + expectTypesEqual('User'); + }); + }); + + describe("type Product", () => { + it("generates proper type", () => { + expectTypesEqual('Product'); + }); + }); + + describe("type UsersList", () => { + it("generates proper type", () => { + expectTypesEqual('UsersList'); + }); + }); + + describe("input ProductsList", () => { + it("generates proper type", () => { + expectTypesEqual('ProductsList'); + }); + }); +}); \ No newline at end of file