From b85ee0571fb52b285bdfdec0aac5f640514aab77 Mon Sep 17 00:00:00 2001 From: Mohammad Ataei Date: Sun, 23 Oct 2022 20:56:57 +0330 Subject: [PATCH] feat: add initial support for on-to-one relations --- src/CollectionStorage.ts | 99 +++++++++++++++++++++++++++-------- src/DocumentCollection.ts | 4 ++ src/Store.ts | 91 +++++++++++++++++++++++++++----- src/createDocument.ts | 47 ----------------- src/types.ts | 2 + src/utils.ts | 17 +++++- tests/create.test.ts | 26 ++++++--- tests/fixtures/index.ts | 5 +- tests/fixtures/schema.graphql | 8 +++ tests/update.test.ts | 10 ++-- tests/utils/factories.ts | 24 ++++++++- 11 files changed, 236 insertions(+), 97 deletions(-) delete mode 100644 src/createDocument.ts diff --git a/src/CollectionStorage.ts b/src/CollectionStorage.ts index 8d499cf..a7770c9 100644 --- a/src/CollectionStorage.ts +++ b/src/CollectionStorage.ts @@ -1,16 +1,27 @@ -import { GraphQLNamedType, GraphQLSchema, isObjectType } from 'graphql' +import { + getNamedType, + GraphQLField, + GraphQLNamedType, + GraphQLObjectType, + GraphQLSchema, + isObjectType, +} from 'graphql' import { DocumentCollection } from './DocumentCollection' +export type Relation = { field: string; type: string } + export class CollectionStorage> { - private schema - private collections + private readonly collections + private readonly _relations constructor(schema: GraphQLSchema) { - this.schema = schema - this.collections = initializeCollections(schema) + const schemaObjectTypes = resolveSchemaObjectTypes(schema) + + this.collections = initializeCollections(schemaObjectTypes) + this._relations = resolveSchemaRelations(schemaObjectTypes) } - get( + collection( type: Type, ): DocumentCollection { const collection = this.collections.get(type) @@ -21,27 +32,25 @@ export class CollectionStorage> { return collection as DocumentCollection } -} -function initializeCollections< - TypesMap extends Record, - TypeName extends keyof TypesMap = keyof TypesMap, ->( - schema: GraphQLSchema, -): Map> { - const nonRootObjectTypeNames = Object.values(schema.getTypeMap()).filter( - isNonRootObjectType, - ) + relations(type: Type): Array { + const relations = this._relations.get(type) - return new Map( - nonRootObjectTypeNames.map((type) => [ - type.name as TypeName, - new DocumentCollection(), - ]), - ) + if (!relations) { + throw new Error('Integrity Failed.') + } + + return relations + } } -function isNonRootObjectType(type: GraphQLNamedType) { +function resolveSchemaObjectTypes(schema: GraphQLSchema): GraphQLObjectType[] { + return Object.values(schema.getTypeMap()).filter(isNonRootObjectType) +} + +function isNonRootObjectType( + type: GraphQLNamedType, +): type is GraphQLObjectType { const rootTypeNames = ['Query', 'Mutation', 'Subscription'] return ( @@ -54,3 +63,47 @@ function isNonRootObjectType(type: GraphQLNamedType) { function isInternalTypeName(typeName: string) { return typeName.startsWith('__') } + +function resolveSchemaRelations< + TypesMap extends Record, + TypeName extends keyof TypesMap = keyof TypesMap, +>(schemaTypes: Array): Map> { + return new Map( + schemaTypes.map((type) => { + return [type.name as TypeName, resolveTypeRelations(type)] + }), + ) +} + +function resolveTypeRelations(type: GraphQLObjectType): Array { + const typeFields = type.getFields() + + return Object.values(typeFields).reduce>((acc, field) => { + if (isRelationField(field)) { + return [ + ...acc, + { field: field.name, type: getNamedType(field.type).name }, + ] + } + + return acc + }, []) +} + +function isRelationField(field: GraphQLField) { + return getNamedType(field.type) instanceof GraphQLObjectType +} + +function initializeCollections< + TypesMap extends Record, + TypeName extends keyof TypesMap = keyof TypesMap, +>( + schemaTypes: Array, +): Map> { + return new Map( + schemaTypes.map((type) => [ + type.name as TypeName, + new DocumentCollection(), + ]), + ) +} diff --git a/src/DocumentCollection.ts b/src/DocumentCollection.ts index f569a3b..80a558b 100644 --- a/src/DocumentCollection.ts +++ b/src/DocumentCollection.ts @@ -40,4 +40,8 @@ export class DocumentCollection { count(): number { return this.documents.size } + + getByKey(documentKey: string) { + return this.documents.get(documentKey) + } } diff --git a/src/Store.ts b/src/Store.ts index 6bb5e5b..6464732 100644 --- a/src/Store.ts +++ b/src/Store.ts @@ -1,29 +1,37 @@ import { GraphQLSchema } from 'graphql' -import { PredicateFunction, Schema } from './types' +import { Document, PredicateFunction, Schema } from './types' import { CollectionStorage } from './CollectionStorage' import { resolveGraphQLSchema } from './resolveGraphQLSchema' -import { createDocument } from './createDocument' -import { getDocumentKey, getDocumentType, isDocument } from './utils' +import { + createDocumentRef, + generateDocumentKey, + getDocumentKey, + getDocumentType, + isDocument, + isDocumentRef, +} from './utils' +import { DOCUMENT_KEY, DOCUMENT_TYPE } from './constants' interface StoreConfiguration { schema: Schema } export class Store> { - protected collections + protected storage protected schema: GraphQLSchema constructor(config: StoreConfiguration) { this.schema = resolveGraphQLSchema(config.schema) - this.collections = new CollectionStorage(this.schema) + this.storage = new CollectionStorage(this.schema) } create( type: Type, data: TypesMap[Type], ): TypesMap[Type] { - const document = createDocument(type as string, data) - return this.collections.get(type).create(document) + const document = this.createDocument(type, data) + + return this.storage.collection(type).create(document) } update( @@ -37,10 +45,10 @@ export class Store> { const type = getDocumentType(document) const key = getDocumentKey(document) - return this.collections - .get(type) + return this.storage + .collection(type) .create( - createDocument( + this.createDocument( type, { ...document, ...data } as TypesMap[string], key, @@ -52,7 +60,7 @@ export class Store> { type: Type, predicate?: PredicateFunction, ): TypesMap[Type] | undefined { - return this.collections.get(type).findFirst(predicate) + return this.storage.collection(type).findFirst(predicate) } findFirstOrThrow( @@ -72,14 +80,69 @@ export class Store> { type: Type, predicate?: PredicateFunction, ): Array { - return this.collections.get(type).find(predicate) + return this.storage.collection(type).find(predicate) } count(type: Type): number { - return this.collections.get(type).count() + return this.storage.collection(type).count() } reset(): void { - this.collections = new CollectionStorage(this.schema) + this.storage = new CollectionStorage(this.schema) + } + + protected createDocument( + type: Type, + data: TypesMap[Type], + documentKey?: string, + ): Document { + const document = structuredClone(data) + + Reflect.defineProperty(document, DOCUMENT_KEY, { + enumerable: false, + configurable: false, + writable: false, + value: documentKey || generateDocumentKey(), + }) + + Reflect.defineProperty(document, DOCUMENT_TYPE, { + enumerable: false, + configurable: false, + writable: false, + value: type, + }) + + this.storage.relations(type).forEach(({ field, type }) => { + if (Array.isArray(document[field])) { + // TODO Implement one-to-many relation + return + } + + const targetDocument = this.create(type, document[field]) + Object.defineProperty(document, field, { + value: createDocumentRef( + getDocumentKey(targetDocument), + getDocumentType(targetDocument), + ), + }) + }) + + return new Proxy(document, { + get: (target, prop) => { + const data = Reflect.get(document, prop) + + if (isDocumentRef(data)) { + return this.storage.collection(data.$ref.type).getByKey(data.$ref.key) + } + + if (Reflect.has(document, prop) || typeof prop !== 'string') { + return Reflect.get(document, prop) + } + }, + + set() { + throw new Error('Documents are immutable.') + }, + }) } } diff --git a/src/createDocument.ts b/src/createDocument.ts deleted file mode 100644 index d270ffa..0000000 --- a/src/createDocument.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { Document } from './types' -import { DOCUMENT_KEY, DOCUMENT_TYPE } from './constants' -import { nanoid } from 'nanoid' - -export function createDocument>( - type: string, - data: DocumentType, - documentKey: string = generateDocumentKey(), -): Document { - const document = structuredClone(data) - - Reflect.defineProperty(document, DOCUMENT_KEY, { - enumerable: false, - configurable: false, - writable: false, - value: documentKey, - }) - - Reflect.defineProperty(document, DOCUMENT_TYPE, { - enumerable: false, - configurable: false, - writable: false, - value: type, - }) - - return createDocumentProxy(document as Document) -} - -function createDocumentProxy>( - document: Document, -): Document { - return new Proxy(document, { - get(target, prop) { - if (Reflect.has(document, prop) || typeof prop !== 'string') { - return Reflect.get(document, prop) - } - }, - - set() { - throw new Error('Documents are immutable.') - }, - }) -} - -function generateDocumentKey() { - return nanoid(16) -} diff --git a/src/types.ts b/src/types.ts index 44a0c35..831713b 100644 --- a/src/types.ts +++ b/src/types.ts @@ -9,3 +9,5 @@ export type Document = DocumentType & { } export type PredicateFunction = (data: T) => boolean + +export type DocumentRef = { $ref: { key: string; type: string } } diff --git a/src/utils.ts b/src/utils.ts index 1b35681..238a685 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,4 +1,5 @@ -import { Document } from './types' +import { nanoid } from 'nanoid' +import { Document, DocumentRef } from './types' import { DOCUMENT_KEY, DOCUMENT_TYPE } from './constants' export function getDocumentKey(document: Document) { @@ -17,3 +18,17 @@ export function isDocument(document: unknown): document is Document { Reflect.has(document, DOCUMENT_TYPE) ) } + +export function generateDocumentKey() { + return nanoid(16) +} + +export function createDocumentRef(key: string, type: string): DocumentRef { + return { $ref: { key, type } } +} + +export function isDocumentRef(input: unknown): input is DocumentRef { + return ( + input !== null && typeof input === 'object' && Reflect.has(input, '$ref') + ) +} diff --git a/tests/create.test.ts b/tests/create.test.ts index 5d704b0..5a178ba 100644 --- a/tests/create.test.ts +++ b/tests/create.test.ts @@ -1,8 +1,7 @@ import { Store } from '../src' -import { schema, TypesMap } from './fixtures' +import { Post, schema, TypesMap, User } from './fixtures' import { postFactory, userFactory } from './utils/factories' import { Document } from '../src/types' -import { Post, User } from './fixtures/schema.types' import { getDocumentKey, getDocumentType } from '../src/utils' const store = new Store({ @@ -11,14 +10,14 @@ const store = new Store({ afterEach(() => store.reset()) -it('can create and store a new entity ', () => { +it('can create and store a new document', () => { const user = userFactory() store.create('User', user) expect(store.findFirstOrThrow('User')).toEqual(user) }) -it('should store document meta data on created entity', () => { +it('should store document meta data on created document', () => { const user = store.create('User', userFactory()) as Document expect(getDocumentKey(user)).toEqual(expect.any(String)) @@ -33,8 +32,11 @@ it('should store document meta data on created entity', () => { it('should store document meta data privately', () => { const data: User = { id: '7c280f0a-c1e7-4982-a008-99d9e1bcbea0', - username: 'Yhudiyt.Wanjala99', + username: 'john.doe', posts: [], + profile: { + fullName: 'John Doe', + }, } const user = store.create('User', data) @@ -44,7 +46,10 @@ it('should store document meta data privately', () => { { "id": "7c280f0a-c1e7-4982-a008-99d9e1bcbea0", "posts": [], - "username": "Yhudiyt.Wanjala99", + "profile": { + "fullName": "John Doe", + }, + "username": "john.doe", } `) @@ -53,3 +58,12 @@ it('should store document meta data privately', () => { // @ts-expect-error DOCUMENT_KEY_SYMBOL should be hidden from return type expect(getDocumentType(user)).toEqual('User') }) + +it('can create new document with `one-to-one` `required` relation', () => { + const data = userFactory() + const user = store.create('User', data) + + expect(user.profile).toEqual(data.profile) + expect(store.findFirstOrThrow('User').profile).toEqual(data.profile) + expect(store.findFirstOrThrow('UserProfile')).toEqual(user.profile) +}) diff --git a/tests/fixtures/index.ts b/tests/fixtures/index.ts index 4c155e5..c5f5edc 100644 --- a/tests/fixtures/index.ts +++ b/tests/fixtures/index.ts @@ -1,9 +1,12 @@ -import { Post, User } from './schema.types' +import { Post, User, UserProfile } from './schema.types' import schema from './schema.graphql' export { schema } +export type { Post, User, UserProfile } + export interface TypesMap { User: User Post: Post + UserProfile: UserProfile } diff --git a/tests/fixtures/schema.graphql b/tests/fixtures/schema.graphql index f10ac06..8e437d0 100644 --- a/tests/fixtures/schema.graphql +++ b/tests/fixtures/schema.graphql @@ -4,9 +4,17 @@ type Post { body: String! } +type UserProfile { + fullName: String! + github: String + twitter: String + website: String +} + type User { id: ID! username: String! + profile: UserProfile! posts: [Post]! } diff --git a/tests/update.test.ts b/tests/update.test.ts index b2ea80f..c234d59 100644 --- a/tests/update.test.ts +++ b/tests/update.test.ts @@ -1,9 +1,9 @@ import { randUserName } from '@ngneat/falso' import { Store } from '../src' import { schema, TypesMap } from './fixtures' -import { userFactory } from './utils/factories' +import { userFactory, userProfileFactory } from './utils/factories' import { Document } from '../src/types' -import { User } from './fixtures/schema.types' +import { User } from './fixtures' import { getDocumentKey, getDocumentType } from '../src/utils' const store = new Store({ @@ -36,7 +36,11 @@ it('should throw error if input document is not valid', () => { it('can update document partially', () => { const user = store.create('User', userFactory()) - const data: Partial = { username: randUserName() } + const data: Partial = { + username: randUserName(), + profile: userProfileFactory(), + } + const updatedUser = store.update(user, data) expect(updatedUser.username).toEqual(data.username) diff --git a/tests/utils/factories.ts b/tests/utils/factories.ts index 80949b6..f3ce45e 100644 --- a/tests/utils/factories.ts +++ b/tests/utils/factories.ts @@ -1,5 +1,12 @@ -import { randUuid, randText, randUserName, randParagraph } from '@ngneat/falso' -import { Post, User } from '../fixtures/schema.types' +import { Post, User, UserProfile } from '../fixtures' +import { + randUuid, + randText, + randUserName, + randParagraph, + randFullName, + randUrl, +} from '@ngneat/falso' export function postFactory(overrides?: Partial): Post { return { @@ -14,7 +21,20 @@ export function userFactory(overrides?: Partial): User { return { id: randUuid(), username: randUserName(), + profile: userProfileFactory(), posts: [], ...overrides, } } + +export function userProfileFactory( + overrides?: Partial, +): UserProfile { + return { + fullName: randFullName(), + github: randUserName(), + twitter: randUserName(), + website: randUrl(), + ...overrides, + } +}