diff --git a/packages/datastore/__tests__/DataStore.ts b/packages/datastore/__tests__/DataStore.ts index 767bf96c9fa..91ecad8f8c0 100644 --- a/packages/datastore/__tests__/DataStore.ts +++ b/packages/datastore/__tests__/DataStore.ts @@ -9,6 +9,7 @@ import { MutableModel, PersistentModelConstructor, Schema, + NonModelTypeConstructor, } from '../src/types'; import StorageType from '../src/storage/storage'; import Observable from 'zen-observable-ts'; @@ -36,12 +37,12 @@ beforeEach(() => { describe('DataStore tests', () => { describe('initSchema tests', () => { - test('Class is created', () => { + test('Model class is created', () => { const classes = initSchema(testSchema()); expect(classes).toHaveProperty('Model'); - const { Model } = classes; + const { Model } = classes as { Model: PersistentModelConstructor }; let property: keyof PersistentModelConstructor = 'copyOf'; expect(Model).toHaveProperty(property); @@ -49,7 +50,7 @@ describe('DataStore tests', () => { expect(typeof Model.copyOf).toBe('function'); }); - test('Class can be instantiated', () => { + test('Model class can be instantiated', () => { const { Model } = initSchema(testSchema()) as { Model: PersistentModelConstructor; }; @@ -92,6 +93,32 @@ describe('DataStore tests', () => { initSchema(testSchema()); }).toThrow('The schema has already been initialized'); }); + + test('Non @model class is created', () => { + const classes = initSchema(testSchema()); + + expect(classes).toHaveProperty('Metadata'); + + const { Metadata } = classes; + + let property: keyof PersistentModelConstructor = 'copyOf'; + expect(Metadata).not.toHaveProperty(property); + }); + + test('Non @model class can be instantiated', () => { + const { Metadata } = initSchema(testSchema()) as { + Metadata: NonModelTypeConstructor; + }; + + const metadata = new Metadata({ + author: 'some author', + tags: [], + }); + + expect(metadata).toBeInstanceOf(Metadata); + + expect(metadata).not.toHaveProperty('id'); + }); }); describe('Immutability', () => { @@ -147,6 +174,20 @@ describe('DataStore tests', () => { // ID should be kept the same expect(model1.id).toBe(model2.id); }); + + test('Non @model - Field cannot be changed', () => { + const { Metadata } = initSchema(testSchema()) as { + Metadata: NonModelTypeConstructor; + }; + + const nonModel = new Metadata({ + author: 'something', + }); + + expect(() => { + (nonModel).author = 'edit'; + }).toThrowError("Cannot assign to read only property 'author' of object"); + }); }); describe('Initialization', () => { @@ -155,7 +196,7 @@ describe('DataStore tests', () => { const classes = initSchema(testSchema()); - const { Model } = classes; + const { Model } = classes as { Model: PersistentModelConstructor }; const promises = [ DataStore.query(Model), @@ -168,18 +209,33 @@ describe('DataStore tests', () => { expect(Storage).toHaveBeenCalledTimes(1); }); - }); - test('It is initialized when observing (no query)', async () => { - Storage = require('../src/storage/storage').default; + test('It is initialized when observing (no query)', async () => { + Storage = require('../src/storage/storage').default; - const classes = initSchema(testSchema()); + const classes = initSchema(testSchema()); - const { Model } = classes; + const { Model } = classes as { Model: PersistentModelConstructor }; - DataStore.observe(Model).subscribe(jest.fn()); + DataStore.observe(Model).subscribe(jest.fn()); - expect(Storage).toHaveBeenCalledTimes(1); + expect(Storage).toHaveBeenCalledTimes(1); + }); + }); + + test("non-@models can't be saved", async () => { + const { Metadata } = initSchema(testSchema()) as { + Metadata: NonModelTypeConstructor; + }; + + const metadata = new Metadata({ + author: 'some author', + tags: [], + }); + + await expect(DataStore.save(metadata)).rejects.toThrow( + 'Object is not an instance of a valid model' + ); }); }); @@ -197,6 +253,12 @@ declare class Model { ): Model; } +export declare class Metadata { + readonly author: string; + readonly tags?: string[]; + constructor(init: Metadata); +} + function testSchema(): Schema { return { enums: {}, @@ -218,6 +280,15 @@ function testSchema(): Schema { type: 'String', isRequired: true, }, + metadata: { + name: 'metadata', + isArray: false, + type: { + type: 'Metadata', + }, + isRequired: false, + attributes: [], + }, }, }, LocalModel: { @@ -240,6 +311,27 @@ function testSchema(): Schema { }, }, }, + types: { + Metadata: { + name: 'Metadata', + fields: { + author: { + name: 'author', + isArray: false, + type: 'String', + isRequired: true, + attributes: [], + }, + tags: { + name: 'tags', + isArray: true, + type: 'String', + isRequired: false, + attributes: [], + }, + }, + }, + }, version: '1', }; } diff --git a/packages/datastore/__tests__/graphql.ts b/packages/datastore/__tests__/graphql.ts new file mode 100644 index 00000000000..dec1a115fbf --- /dev/null +++ b/packages/datastore/__tests__/graphql.ts @@ -0,0 +1,181 @@ +import { parse, print } from 'graphql'; +import { SchemaNamespace } from '../src'; +import { + buildGraphQLOperation, + buildSubscriptionGraphQLOperation, + TransformerMutationType, +} from '../src/sync/utils'; +import { newSchema } from './schema'; + +const postSelectionSet = ` +id +title +metadata { + rating + tags + nested { + aField + } +} +_version +_lastChangedAt +_deleted +reference { + id + _deleted +} +blog { + id + _deleted +} +`; + +describe('DataStore GraphQL generation', () => { + test.each([ + [ + 'LIST', + /* GraphQL */ ` + query operation( + $limit: Int + $nextToken: String + $lastSync: AWSTimestamp + ) { + syncPosts(limit: $limit, nextToken: $nextToken, lastSync: $lastSync) { + items { + ${postSelectionSet} + } + nextToken + startedAt + } + } + `, + ], + [ + 'CREATE', + /* GraphQL */ ` + mutation operation($input: CreatePostInput!) { + createPost(input: $input) { + ${postSelectionSet} + } + } + `, + ], + [ + 'UPDATE', + /* GraphQL */ ` + mutation operation( + $input: UpdatePostInput! + $condition: ModelPostConditionInput + ) { + updatePost(input: $input, condition: $condition) { + ${postSelectionSet} + } + } + `, + ], + , + [ + 'DELETE', + /* GraphQL */ ` + mutation operation( + $input: DeletePostInput! + $condition: ModelPostConditionInput + ) { + deletePost(input: $input, condition: $condition) { + ${postSelectionSet} + } + } + `, + ], + , + [ + 'GET', + /* GraphQL */ ` + query operation($id: ID!) { + getPost(id: $id) { + ${postSelectionSet} + } + } + `, + ], + ])( + '%s - has full selection set including types, and inputs', + (graphQLOpType, expectedGraphQL) => { + const namespace = (newSchema); + + const { + models: { Post: postModelDefinition }, + } = namespace; + + const [[, , query]] = buildGraphQLOperation( + namespace, + postModelDefinition, + graphQLOpType + ); + + expect(print(parse(query))).toStrictEqual(print(parse(expectedGraphQL))); + } + ); + + test.each([ + [ + TransformerMutationType.CREATE, + /* GraphQL */ ` + subscription operation { + onCreatePost { + ${postSelectionSet} + } + } + `, + ], + [ + TransformerMutationType.GET, + /* GraphQL */ ` + subscription operation { + onGetPost { + ${postSelectionSet} + } + } + `, + ], + [ + TransformerMutationType.UPDATE, + /* GraphQL */ ` + subscription operation { + onUpdatePost { + ${postSelectionSet} + } + } + `, + ], + [ + TransformerMutationType.DELETE, + /* GraphQL */ ` + subscription operation { + onDeletePost { + ${postSelectionSet} + } + } + `, + ], + ])( + 'Subscription (%s) - has full selection set including types, and inputs', + (transformerMutationType, expectedGraphQL) => { + const namespace = (newSchema); + + const { + models: { Post: postModelDefinition }, + } = namespace; + + const [, , query] = buildSubscriptionGraphQLOperation( + namespace, + postModelDefinition, + transformerMutationType, + false, + '' + ); + + expect(print(parse(query))).toStrictEqual(print(parse(expectedGraphQL))); + } + ); +}); diff --git a/packages/datastore/__tests__/indexeddb.test.ts b/packages/datastore/__tests__/indexeddb.test.ts index b674a99b8bc..83e36788dae 100644 --- a/packages/datastore/__tests__/indexeddb.test.ts +++ b/packages/datastore/__tests__/indexeddb.test.ts @@ -8,6 +8,8 @@ import { Blog, Comment, PostAuthorJoin, + PostMetadata, + Nested, } from './model'; import { USER } from '../src/util'; let db: idb.IDBPDatabase; @@ -99,6 +101,35 @@ describe('Indexed db storage test', () => { ); }); + test('save stores non-model types along the item (including nested)', async () => { + const p = new Post({ + title: 'Avatar', + blog, + metadata: new PostMetadata({ + rating: 3, + tags: ['a', 'b', 'c'], + nested: new Nested({ + aField: 'Some value', + }), + }), + }); + + await DataStore.save(p); + + const postFromDB = await db + .transaction(`${USER}_Post`, 'readonly') + .objectStore(`${USER}_Post`) + .get(p.id); + + expect(postFromDB.metadata).toMatchObject({ + rating: 3, + tags: ['a', 'b', 'c'], + nested: new Nested({ + aField: 'Some value', + }), + }); + }); + test('save function 1:M insert', async () => { // test 1:M const p = new Post({ diff --git a/packages/datastore/__tests__/model.ts b/packages/datastore/__tests__/model.ts index 65a56bc1fbc..37419093ded 100644 --- a/packages/datastore/__tests__/model.ts +++ b/packages/datastore/__tests__/model.ts @@ -4,7 +4,7 @@ import { PersistentModelConstructor, } from '@aws-amplify/datastore'; -import { initSchema } from '../src/index'; +import { initSchema, NonModelTypeConstructor } from '../src/index'; import { newSchema } from './schema'; declare class BlogModel { @@ -26,6 +26,7 @@ declare class PostModel { readonly reference?: PostModel; readonly comments?: CommentModel[]; readonly authors?: PostAuthorJoinModel[]; + readonly metadata?: PostMetadataType; constructor(init: ModelInit); static copyOf( source: PostModel, @@ -33,6 +34,18 @@ declare class PostModel { ): PostModel; } +declare class PostMetadataType { + readonly rating: number; + readonly tags?: string[]; + readonly nested?: NestedType; + constructor(init: ModelInit); +} + +declare class NestedType { + readonly aField: string; + constructor(init: ModelInit); +} + declare class CommentModel { readonly id: string; readonly content?: string; @@ -85,15 +98,33 @@ declare class BlogOwnerModel { ): BlogOwnerModel; } -const { Author, Post, Comment, Blog, BlogOwner, PostAuthorJoin } = initSchema( - newSchema -) as { +const { + Author, + Post, + Comment, + Blog, + BlogOwner, + PostAuthorJoin, + PostMetadata, + Nested, +} = initSchema(newSchema) as { Author: PersistentModelConstructor; Post: PersistentModelConstructor; Comment: PersistentModelConstructor; Blog: PersistentModelConstructor; BlogOwner: PersistentModelConstructor; PostAuthorJoin: PersistentModelConstructor; + PostMetadata: NonModelTypeConstructor; + Nested: NonModelTypeConstructor; +}; +``; +export { + Author, + Post, + Comment, + Blog, + BlogOwner, + PostAuthorJoin, + PostMetadata, + Nested, }; - -export { Author, Post, Comment, Blog, BlogOwner, PostAuthorJoin }; diff --git a/packages/datastore/__tests__/schema.ts b/packages/datastore/__tests__/schema.ts index ec5f4eea855..b565db6d845 100644 --- a/packages/datastore/__tests__/schema.ts +++ b/packages/datastore/__tests__/schema.ts @@ -132,6 +132,15 @@ export const newSchema: Schema = { associatedWith: 'post', }, }, + metadata: { + name: 'metadata', + isArray: false, + type: { + type: 'PostMetadata', + }, + isRequired: false, + attributes: [], + }, }, }, Comment: { @@ -316,5 +325,47 @@ export const newSchema: Schema = { }, }, enums: {}, + types: { + PostMetadata: { + name: 'PostMetadata', + fields: { + author: { + name: 'rating', + isArray: false, + type: 'Int', + isRequired: true, + attributes: [], + }, + tags: { + name: 'tags', + isArray: true, + type: 'String', + isRequired: false, + attributes: [], + }, + nested: { + name: 'nested', + isArray: false, + type: { + type: 'Nested', + }, + isRequired: true, + attributes: [], + }, + }, + }, + Nested: { + name: 'Nested', + fields: { + aField: { + name: 'aField', + isArray: false, + type: 'String', + isRequired: true, + attributes: [], + }, + }, + }, + }, version: 'a66372d29356c40e7cd29e41527cead7', }; diff --git a/packages/datastore/src/datastore/datastore.ts b/packages/datastore/src/datastore/datastore.ts index f138cde7b12..b9224a7b5a9 100644 --- a/packages/datastore/src/datastore/datastore.ts +++ b/packages/datastore/src/datastore/datastore.ts @@ -15,12 +15,15 @@ import { GraphQLScalarType, InternalSchema, isGraphQLScalarType, + ModelFields, ModelFieldType, ModelInit, ModelInstanceMetadata, + ModelOrTypeConstructorMap, ModelPredicate, MutableModel, NamespaceResolver, + NonModelTypeConstructor, PaginationInput, PersistentModel, PersistentModelConstructor, @@ -28,6 +31,7 @@ import { Schema, SchemaModel, SchemaNamespace, + SchemaType, SubscriptionMessage, SyncConflict, SyncError, @@ -62,7 +66,7 @@ const SETTING_SCHEMA_VERSION = 'schemaVersion'; let storage: Storage; let schema: InternalSchema; -const classNamespaceMap = new WeakMap< +const modelNamespaceMap = new WeakMap< PersistentModelConstructor, string >(); @@ -70,7 +74,7 @@ const classNamespaceMap = new WeakMap< const getModelDefinition = ( modelConstructor: PersistentModelConstructor ) => { - const namespace = classNamespaceMap.get(modelConstructor); + const namespace = modelNamespaceMap.get(modelConstructor); return schema.namespaces[namespace].models[modelConstructor.name]; }; @@ -78,27 +82,19 @@ const getModelDefinition = ( const isValidModelConstructor = ( obj: any ): obj is PersistentModelConstructor => { - return isModelConstructor(obj) && classNamespaceMap.has(obj); + return isModelConstructor(obj) && modelNamespaceMap.has(obj); }; const namespaceResolver: NamespaceResolver = modelConstructor => - classNamespaceMap.get(modelConstructor); + modelNamespaceMap.get(modelConstructor); -let dataStoreClasses: { - [modelName: string]: PersistentModelConstructor; -}; +let dataStoreClasses: ModelOrTypeConstructorMap; -let userClasses: { - [modelName: string]: PersistentModelConstructor; -}; +let userClasses: ModelOrTypeConstructorMap; -let syncClasses: { - [modelName: string]: PersistentModelConstructor; -}; +let syncClasses: ModelOrTypeConstructorMap; -let storageClasses: { - [modelName: string]: PersistentModelConstructor; -}; +let storageClasses: ModelOrTypeConstructorMap; const initSchema = (userSchema: Schema) => { if (schema !== undefined) { @@ -113,16 +109,16 @@ const initSchema = (userSchema: Schema) => { }; logger.log('DataStore', 'Init models'); - userClasses = createModelClassses(internalUserNamespace); + userClasses = createModelAndTypeClassses(internalUserNamespace); logger.log('DataStore', 'Models initialized'); const dataStoreNamespace = getNamespace(); const storageNamespace = Storage.getNamespace(); const syncNamespace = SyncEngine.getNamespace(); - dataStoreClasses = createModelClassses(dataStoreNamespace); - storageClasses = createModelClassses(storageNamespace); - syncClasses = createModelClassses(syncNamespace); + dataStoreClasses = createModelAndTypeClassses(dataStoreNamespace); + storageClasses = createModelAndTypeClassses(storageNamespace); + syncClasses = createModelAndTypeClassses(syncNamespace); schema = { namespaces: { @@ -187,22 +183,25 @@ const initSchema = (userSchema: Schema) => { return userClasses; }; -const createModelClassses: ( +const createModelAndTypeClassses: ( namespace: SchemaNamespace -) => { - [modelName: string]: PersistentModelConstructor; -} = namespace => { - const classes: { - [modelName: string]: PersistentModelConstructor; - } = {}; +) => ModelOrTypeConstructorMap = namespace => { + const classes: ModelOrTypeConstructorMap = {}; Object.entries(namespace.models).forEach(([modelName, modelDefinition]) => { const clazz = createModelClass(modelDefinition); classes[modelName] = clazz; - classNamespaceMap.set(clazz, namespace.name); + modelNamespaceMap.set(clazz, namespace.name); }); + Object.entries(namespace.types || {}).forEach( + ([typeName, typeDefinition]) => { + const clazz = createTypeClass(typeDefinition); + classes[typeName] = clazz; + } + ); + return classes; }; @@ -220,72 +219,80 @@ function modelInstanceCreator( return new modelConstructor(init); } +const _cosa = ( + init: ModelInit, + modelDefinition: SchemaModel | SchemaType, + draft: Draft +) => { + Object.entries(init).forEach(([k, v]) => { + const fieldDefinition = modelDefinition.fields[k]; + + if (fieldDefinition !== undefined) { + const { type, isRequired, name, isArray } = fieldDefinition; + + if (isRequired && (v === null || v === undefined)) { + throw new Error(`Field ${name} is required`); + } + + if (isGraphQLScalarType(type)) { + const jsType = GraphQLScalarType.getJSType(type); + + if (isArray) { + if (!Array.isArray(v)) { + throw new Error( + `Field ${name} should be of type ${jsType}[], ${typeof v} received. ${v}` + ); + } + + if ((<[]>v).some(e => typeof e !== jsType)) { + const elemTypes = (<[]>v).map(e => typeof e).join(','); + + throw new Error( + `All elements in the ${name} array should be of type ${jsType}, [${elemTypes}] received. ${v}` + ); + } + } else if (typeof v !== jsType && v !== null) { + throw new Error( + `Field ${name} should be of type ${jsType}, ${typeof v} received. ${v}` + ); + } + } + } + + (draft)[k] = v; + }); +}; + const createModelClass = ( modelDefinition: SchemaModel ) => { const clazz = >(class Model { constructor(init: ModelInit) { - const modelInstanceMetadata: ModelInstanceMetadata = instancesMetadata.has( - init - ) - ? (init) - : {}; - const { - id: _id, - _version, - _lastChangedAt, - _deleted, - } = modelInstanceMetadata; - - const id = - // instancesIds is set by modelInstanceCreator, it is accessible only internally - _id !== null && _id !== undefined - ? _id - : modelDefinition.syncable - ? uuid4() - : // Transform UUID v1 into a lexicographically sortable string - uuid1().replace(/^(.{8})-(.{4})-(.{4})/, '$3-$2-$1'); - const instance = produce( this, (draft: Draft) => { - Object.entries(init).forEach(([k, v]) => { - const fieldDefinition = modelDefinition.fields[k]; - - if (fieldDefinition !== undefined) { - const { type, isRequired, name, isArray } = fieldDefinition; - - if (isRequired && (v === null || v === undefined)) { - throw new Error(`Field ${name} is required`); - } - - if (isGraphQLScalarType(type)) { - const jsType = GraphQLScalarType.getJSType(type); - - if (isArray) { - if (!Array.isArray(v)) { - throw new Error( - `Field ${name} should be of type ${jsType}[], ${typeof v} received. ${v}` - ); - } - - if ((<[]>v).some(e => typeof e !== jsType)) { - const elemTypes = (<[]>v).map(e => typeof e).join(','); - - throw new Error( - `All elements in the ${name} array should be of type ${jsType}, [${elemTypes}] received. ${v}` - ); - } - } else if (typeof v !== jsType && v !== null) { - throw new Error( - `Field ${name} should be of type ${jsType}, ${typeof v} received. ${v}` - ); - } - } - } - - (draft)[k] = v; - }); + _cosa(init, modelDefinition, draft); + + const modelInstanceMetadata: ModelInstanceMetadata = instancesMetadata.has( + init + ) + ? (init) + : {}; + const { + id: _id, + _version, + _lastChangedAt, + _deleted, + } = modelInstanceMetadata; + + const id = + // instancesIds is set by modelInstanceCreator, it is accessible only internally + _id !== null && _id !== undefined + ? _id + : modelDefinition.syncable + ? uuid4() + : // Transform UUID v1 into a lexicographically sortable string for non-syncable models + uuid1().replace(/^(.{8})-(.{4})-(.{4})/, '$3-$2-$1'); draft.id = id; @@ -301,19 +308,28 @@ const createModelClass = ( } static copyOf(source: T, fn: (draft: MutableModel) => T) { - if ( - !isValidModelConstructor( - Object.getPrototypeOf(source || {}).constructor - ) - ) { + const modelConstructor = Object.getPrototypeOf(source || {}).constructor; + + if (!isValidModelConstructor(modelConstructor)) { const msg = 'The source object is not a valid model'; logger.error(msg, { source }); throw new Error(msg); } + const namespaceName = namespaceResolver(modelConstructor); + const namespace = schema.namespaces[namespaceName]; + const nonModelTypes = namespace.types || {}; + + const nonModelTypeFields: ModelFields = {}; + Object.entries(modelDefinition.fields).forEach(([fieldName, field]) => { + if (field.type in nonModelTypes) { + nonModelTypeFields[fieldName] = field; + } + }); + return produce(source, draft => { - fn(draft); + fn(>draft); draft.id = source.id; }); } @@ -326,6 +342,29 @@ const createModelClass = ( return clazz; }; +const createTypeClass = ( + typeDefinition: SchemaType +) => { + const clazz = >(class Model { + constructor(init: ModelInit) { + const instance = produce( + this, + (draft: Draft) => { + _cosa(init, typeDefinition, draft); + } + ); + + return instance; + } + }); + + clazz[immerable] = true; + + Object.defineProperty(clazz, 'name', { value: typeDefinition.name }); + + return clazz; +}; + const save = async ( model: T, condition?: ProducerModelPredicate @@ -671,20 +710,35 @@ function defaultErrorHandler(error: SyncError) { function getModelConstructorByModelName( namespaceName: NAMESPACES, modelName: string -) { +): PersistentModelConstructor { + let result: PersistentModelConstructor | NonModelTypeConstructor; + switch (namespaceName) { case DATASTORE: - return dataStoreClasses[modelName]; + result = dataStoreClasses[modelName]; + break; case USER: - return userClasses[modelName]; + result = userClasses[modelName]; + break; case SYNC: - return syncClasses[modelName]; + result = syncClasses[modelName]; + break; case STORAGE: - return storageClasses[modelName]; + result = storageClasses[modelName]; + break; default: exhaustiveCheck(namespaceName); break; } + + if (isValidModelConstructor(result)) { + return result; + } else { + const msg = `Model name is not valid for namespace. modelName: ${modelName}, namespace: ${namespaceName}`; + logger.error(msg); + + throw new Error(msg); + } } async function checkSchemaVersion( @@ -794,6 +848,7 @@ function getNamespace(): SchemaNamespace { name: DATASTORE, relationships: {}, enums: {}, + types: {}, models: { Setting: { name: 'Setting', diff --git a/packages/datastore/src/storage/storage.ts b/packages/datastore/src/storage/storage.ts index 3909aa68e49..71fb6a7f36e 100644 --- a/packages/datastore/src/storage/storage.ts +++ b/packages/datastore/src/storage/storage.ts @@ -54,6 +54,7 @@ class Storage implements StorageFacade { relationships: {}, enums: {}, models: {}, + types: {}, }; return namespace; diff --git a/packages/datastore/src/sync/index.ts b/packages/datastore/src/sync/index.ts index a1bafb5c8d4..a99c2dcdecc 100644 --- a/packages/datastore/src/sync/index.ts +++ b/packages/datastore/src/sync/index.ts @@ -8,6 +8,7 @@ import { ErrorHandler, InternalSchema, ModelInit, + ModelOrTypeConstructorMap, MutableModel, NamespaceResolver, PersistentModelConstructor, @@ -79,21 +80,17 @@ export class SyncEngine { constructor( private readonly schema: InternalSchema, private readonly namespaceResolver: NamespaceResolver, - private readonly modelClasses: Record< - string, - PersistentModelConstructor - >, - private readonly userModelClasses: Record< - string, - PersistentModelConstructor - >, + private readonly modelClasses: ModelOrTypeConstructorMap, + private readonly userModelClasses: ModelOrTypeConstructorMap, private readonly storage: Storage, private readonly modelInstanceCreator: ModelInstanceCreator, private readonly maxRecordsToSync: number, conflictHandler: ConflictHandler, errorHandler: ErrorHandler ) { - const MutationEvent = this.modelClasses['MutationEvent']; + const MutationEvent = this.modelClasses[ + 'MutationEvent' + ] as PersistentModelConstructor; this.outbox = new MutationEventOutbox( this.schema, @@ -205,7 +202,7 @@ export class SyncEngine { ([_transformerMutationType, modelDefinition, item]) => { const modelConstructor = this.userModelClasses[ modelDefinition.name - ]; + ] as PersistentModelConstructor; const model = this.modelInstanceCreator( modelConstructor, @@ -224,7 +221,7 @@ export class SyncEngine { ([_transformerMutationType, modelDefinition, item]) => { const modelConstructor = this.userModelClasses[ modelDefinition.name - ]; + ] as PersistentModelConstructor; const model = this.modelInstanceCreator( modelConstructor, @@ -256,7 +253,7 @@ export class SyncEngine { ]; const MutationEventConstructor = this.modelClasses[ 'MutationEvent' - ]; + ] as PersistentModelConstructor; const graphQLCondition = predicateToGraphQLCondition(condition); const mutationEvent = createMutationInstanceFromModelOperation( namespace.relationships, @@ -340,7 +337,7 @@ export class SyncEngine { const promises = items.map(async item => { const modelConstructor = this.userModelClasses[ modelDefinition.name - ]; + ] as PersistentModelConstructor; const model = this.modelInstanceCreator(modelConstructor, item); @@ -358,7 +355,8 @@ export class SyncEngine { modelDefinition.name ); - modelMetadata = this.modelClasses.ModelMetadata.copyOf( + modelMetadata = (this.modelClasses + .ModelMetadata as PersistentModelConstructor).copyOf( modelMetadata, draft => { draft.lastSync = startedAt; @@ -473,7 +471,9 @@ export class SyncEngine { ); } else { await this.storage.save( - this.modelClasses.ModelMetadata.copyOf(modelMetadata, draft => { + (this.modelClasses.ModelMetadata as PersistentModelConstructor< + any + >).copyOf(modelMetadata, draft => { draft.fullSyncInterval = fullSyncInterval; }) ); @@ -531,6 +531,7 @@ export class SyncEngine { values: ['CREATE', 'UPDATE', 'DELETE'], }, }, + types: {}, models: { MutationEvent: { name: 'MutationEvent', diff --git a/packages/datastore/src/sync/processors/mutation.ts b/packages/datastore/src/sync/processors/mutation.ts index 84954a973b4..142605f3577 100644 --- a/packages/datastore/src/sync/processors/mutation.ts +++ b/packages/datastore/src/sync/processors/mutation.ts @@ -15,12 +15,13 @@ import { GraphQLCondition, InternalSchema, isModelFieldType, + isTargetNameAssociation, ModelInstanceMetadata, + ModelOrTypeConstructorMap, OpType, PersistentModel, PersistentModelConstructor, SchemaModel, - isTargetNameAssociation, } from '../../types'; import { exhaustiveCheck, USER } from '../../util'; import { MutationEventOutbox } from '../outbox'; @@ -47,9 +48,7 @@ class MutationProcessor { constructor( private readonly schema: InternalSchema, private readonly storage: Storage, - private readonly userClasses: { - [modelName: string]: PersistentModelConstructor; - }, + private readonly userClasses: ModelOrTypeConstructorMap, private readonly outbox: MutationEventOutbox, private readonly modelInstanceCreator: ModelInstanceCreator, private readonly MutationEvent: PersistentModelConstructor, @@ -64,9 +63,21 @@ class MutationProcessor { Object.values(namespace.models) .filter(({ syncable }) => syncable) .forEach(model => { - const [createMutation] = buildGraphQLOperation(model, 'CREATE'); - const [updateMutation] = buildGraphQLOperation(model, 'UPDATE'); - const [deleteMutation] = buildGraphQLOperation(model, 'DELETE'); + const [createMutation] = buildGraphQLOperation( + namespace, + model, + 'CREATE' + ); + const [updateMutation] = buildGraphQLOperation( + namespace, + model, + 'UPDATE' + ); + const [deleteMutation] = buildGraphQLOperation( + namespace, + model, + 'DELETE' + ); this.typeQuery.set(model, [ createMutation, @@ -106,16 +117,20 @@ class MutationProcessor { this.processing = true; let head: MutationEvent; + const namespaceName = USER; // start to drain outbox while (this.processing && (head = await this.outbox.peek(this.storage))) { const { model, operation, data, condition } = head; - const modelConstructor = this.userClasses[model]; + const modelConstructor = this.userClasses[ + model + ] as PersistentModelConstructor; let result: GraphQLResult>; let opName: string; let modelDefinition: SchemaModel; try { [result, opName, modelDefinition] = await this.jitteredRetry( + namespaceName, model, operation, data, @@ -147,6 +162,7 @@ class MutationProcessor { } private async jitteredRetry( + namespaceName: string, model: string, operation: TransformerMutationType, data: string, @@ -173,7 +189,13 @@ class MutationProcessor { graphQLCondition, opName, modelDefinition, - ] = this.createQueryVariables(model, operation, data, condition); + ] = this.createQueryVariables( + namespaceName, + model, + operation, + data, + condition + ); const tryWith = { query, variables }; let attempt = 0; @@ -228,6 +250,7 @@ class MutationProcessor { // Query latest from server and notify merger const [[, opName, query]] = buildGraphQLOperation( + this.schema.namespaces[namespaceName], modelDefinition, 'GET' ); @@ -242,7 +265,7 @@ class MutationProcessor { return [serverData, opName, modelDefinition]; } - const namespace = this.schema.namespaces[USER]; + const namespace = this.schema.namespaces[namespaceName]; // convert retry with to tryWith const updatedMutation = createMutationInstanceFromModelOperation( @@ -305,12 +328,13 @@ class MutationProcessor { } private createQueryVariables( + namespaceName: string, model: string, operation: TransformerMutationType, data: string, condition: string ): [string, Record, GraphQLCondition, string, SchemaModel] { - const modelDefinition = this.schema.namespaces[USER].models[model]; + const modelDefinition = this.schema.namespaces[namespaceName].models[model]; const queriesTuples = this.typeQuery.get(modelDefinition); @@ -322,7 +346,7 @@ class MutationProcessor { const filteredData = operation === TransformerMutationType.DELETE - ? { id: parsedData.id } + ? { id: parsedData.id } // For DELETE mutations, only ID is sent : Object.values(modelDefinition.fields) .filter(({ type, association }) => { // connections @@ -339,7 +363,7 @@ class MutationProcessor { return false; } - // scalars + // scalars and non-model types return true; }) .map(({ name, type, association }) => { @@ -361,6 +385,7 @@ class MutationProcessor { return acc; }, {}); + // Build mutation variables input object const input: ModelInstanceMetadata = { ...filteredData, _version, diff --git a/packages/datastore/src/sync/processors/subscription.ts b/packages/datastore/src/sync/processors/subscription.ts index 85175b2fe43..cfc8e7b1230 100644 --- a/packages/datastore/src/sync/processors/subscription.ts +++ b/packages/datastore/src/sync/processors/subscription.ts @@ -1,13 +1,15 @@ -import '@aws-amplify/pubsub'; - -import Observable from 'zen-observable-ts'; - import API, { GraphQLResult, GRAPHQL_AUTH_MODE } from '@aws-amplify/api'; import Auth from '@aws-amplify/auth'; import Cache from '@aws-amplify/cache'; import { ConsoleLogger as Logger, Hub } from '@aws-amplify/core'; - -import { InternalSchema, PersistentModel, SchemaModel } from '../../types'; +import '@aws-amplify/pubsub'; +import Observable from 'zen-observable-ts'; +import { + InternalSchema, + PersistentModel, + SchemaModel, + SchemaNamespace, +} from '../../types'; import { buildSubscriptionGraphQLOperation, getAuthorizationRules, @@ -41,6 +43,7 @@ class SubscriptionProcessor { constructor(private readonly schema: InternalSchema) {} private buildSubscription( + namespace: SchemaNamespace, model: SchemaModel, transformerMutationType: TransformerMutationType, userCredentials: USER_CREDENTIALS, @@ -65,6 +68,7 @@ class SubscriptionProcessor { ) || {}; const [opType, opName, query] = buildSubscriptionGraphQLOperation( + namespace, model, transformerMutationType, isOwner, @@ -278,6 +282,7 @@ class SubscriptionProcessor { TransformerMutationType.DELETE, ].map(op => this.buildSubscription( + namespace, modelDefinition, op, userCredentials, diff --git a/packages/datastore/src/sync/processors/sync.ts b/packages/datastore/src/sync/processors/sync.ts index 9be0e42fad4..942427a819d 100644 --- a/packages/datastore/src/sync/processors/sync.ts +++ b/packages/datastore/src/sync/processors/sync.ts @@ -26,9 +26,11 @@ class SyncProcessor { Object.values(namespace.models) .filter(({ syncable }) => syncable) .forEach(model => { - const [ - [_transformerMutationType, ...opNameQuery], - ] = buildGraphQLOperation(model, 'LIST'); + const [[, ...opNameQuery]] = buildGraphQLOperation( + namespace, + model, + 'LIST' + ); this.typeQuery.set(model, opNameQuery); }); diff --git a/packages/datastore/src/sync/utils.ts b/packages/datastore/src/sync/utils.ts index f0dca1c9868..31e10eddcd7 100644 --- a/packages/datastore/src/sync/utils.ts +++ b/packages/datastore/src/sync/utils.ts @@ -5,7 +5,9 @@ import { isEnumFieldType, isGraphQLScalarType, isPredicateObj, + isSchemaModel, isTargetNameAssociation, + isTypeFieldType, ModelFields, ModelInstanceMetadata, OpType, @@ -14,6 +16,8 @@ import { PredicatesGroup, RelationshipType, SchemaModel, + SchemaNamespace, + SchemaType, } from '../types'; import { exhaustiveCheck } from '../util'; import { MutationEvent } from './'; @@ -46,21 +50,31 @@ export function getMetadataFields(): ReadonlyArray { return metadataFields; } -// TODO: Ask for parent/children ids -function generateSelectionSet(modelDefinition: SchemaModel): string { +function generateSelectionSet( + namespace: SchemaNamespace, + modelDefinition: SchemaModel | SchemaType +): string { const scalarFields = getScalarFields(modelDefinition); + const nonModelFields = getNonModelFields(namespace, modelDefinition); - const scalarAndMetadataFields = Object.values(scalarFields) + let scalarAndMetadataFields = Object.values(scalarFields) .map(({ name }) => name) - .concat(getMetadataFields()) - .concat(getConnectionFields(modelDefinition)); + .concat(nonModelFields); + + if (isSchemaModel(modelDefinition)) { + scalarAndMetadataFields = scalarAndMetadataFields + .concat(getMetadataFields()) + .concat(getConnectionFields(modelDefinition)); + } const result = scalarAndMetadataFields.join('\n'); return result; } -function getScalarFields(modelDefinition: SchemaModel): ModelFields { +function getScalarFields( + modelDefinition: SchemaModel | SchemaType +): ModelFields { const { fields } = modelDefinition; const result = Object.values(fields) @@ -106,6 +120,39 @@ function getConnectionFields(modelDefinition: SchemaModel): string[] { return result; } +function getNonModelFields( + namespace: SchemaNamespace, + modelDefinition: SchemaModel | SchemaType +): string[] { + const result = []; + + Object.values(modelDefinition.fields).forEach(({ name, type }) => { + if (isTypeFieldType(type)) { + const typeDefinition = namespace.types![type.type]; + const scalarFields = Object.values(getScalarFields(typeDefinition)).map( + ({ name }) => name + ); + + const nested = []; + Object.values(typeDefinition.fields).forEach(field => { + const { type, name } = field; + + if (isTypeFieldType(type)) { + const typeDefinition = namespace.types![type.type]; + + nested.push( + `${name} { ${generateSelectionSet(namespace, typeDefinition)} }` + ); + } + }); + + result.push(`${name} { ${scalarFields.join(' ')} ${nested.join(' ')} }`); + } + }); + + return result; +} + export function getAuthorizationRules( modelDefinition: SchemaModel, transformerOpType: TransformerMutationType @@ -158,12 +205,13 @@ export function getAuthorizationRules( } export function buildSubscriptionGraphQLOperation( + namespace: SchemaNamespace, modelDefinition: SchemaModel, transformerMutationType: TransformerMutationType, isOwnerAuthorization: boolean, ownerField: string ): [TransformerMutationType, string, string] { - const selectionSet = generateSelectionSet(modelDefinition); + const selectionSet = generateSelectionSet(namespace, modelDefinition); const { name: typeName, pluralName: pluralTypeName } = modelDefinition; @@ -188,10 +236,11 @@ export function buildSubscriptionGraphQLOperation( } export function buildGraphQLOperation( + namespace: SchemaNamespace, modelDefinition: SchemaModel, graphQLOpType: keyof typeof GraphQLOperationType ): [TransformerMutationType, string, string][] { - let selectionSet = generateSelectionSet(modelDefinition); + let selectionSet = generateSelectionSet(namespace, modelDefinition); const { name: typeName, pluralName: pluralTypeName } = modelDefinition; diff --git a/packages/datastore/src/types.ts b/packages/datastore/src/types.ts index 2d387277850..36aa4a16e63 100644 --- a/packages/datastore/src/types.ts +++ b/packages/datastore/src/types.ts @@ -7,6 +7,7 @@ export type Schema = UserSchema & { }; export type UserSchema = { models: SchemaModels; + types?: SchemaTypes; relationships?: RelationshipType; enums: SchemaEnums; modelTopologicalOrdering?: Map; @@ -27,6 +28,14 @@ export type SchemaModel = { fields: ModelFields; syncable?: boolean; }; +export function isSchemaModel(obj: any): obj is SchemaModel { + return obj && (obj).pluralName !== undefined; +} +export type SchemaTypes = Record; +export type SchemaType = { + name: string; + fields: ModelFields; +}; type SchemaEnums = Record; type SchemaEnum = { name: string; @@ -124,6 +133,14 @@ export function isModelFieldType(obj: any): obj is ModelFieldType { return false; } +export type TypeFieldType = { type: string }; +export function isTypeFieldType(obj: any): obj is TypeFieldType { + const typeField: keyof TypeFieldType = 'type'; + if (obj && obj[typeField]) return true; + + return false; +} + type EnumFieldType = { enum: string }; export function isEnumFieldType(obj: any): obj is EnumFieldType { const modelField: keyof EnumFieldType = 'enum'; @@ -137,6 +154,7 @@ type ModelField = { type: | keyof Omit | ModelFieldType + | TypeFieldType | EnumFieldType; isArray: boolean; isRequired?: boolean; @@ -146,18 +164,25 @@ type ModelField = { //#endregion //#region Model definition +export type NonModelTypeConstructor = { + new (init: T): T; +}; export type PersistentModelConstructor = { new (init: ModelInit): T; copyOf(src: T, mutator: (draft: MutableModel) => T | void): T; }; +export type ModelOrTypeConstructorMap = Record< + string, + PersistentModelConstructor | NonModelTypeConstructor +>; export type PersistentModel = Readonly<{ id: string } & Record>; export type ModelInit = Omit; -export type MutableModel = Omit< - { - -readonly [P in keyof T]: T[P]; - }, - 'id' ->; +type DeepWritable = { + -readonly [P in keyof T]: T[P] extends TypeName + ? T[P] + : DeepWritable; +}; +export type MutableModel = Omit, 'id'>; export type ModelInstanceMetadata = { id: string;