Skip to content

Commit

Permalink
feat(@aws-amplify/datastore): expose timestamp fields and prevent wri…
Browse files Browse the repository at this point in the history
…ting to read-only fields (#8509)

* fix(@aws-amplify/datastore): expose timestamp fields and prevent writing to read-only fields

* fix(@aws-amplify/datastore): add model meta data type

* fix(@aws-amplify/datastore): fix compilation errors

* fix(@aws-amplify/datastore): expose timestamp fields and prevent writing to read-only fields

* fix(@aws-amplify/datastore): add model meta data type

* fix(@aws-amplify/datastore): fix compilation errors

* test(@aws-amplify/datastore): bypass TS compilation errors for unit tests
  • Loading branch information
nickarocho authored Jul 1, 2021
1 parent 37422f2 commit 10857d5
Show file tree
Hide file tree
Showing 4 changed files with 154 additions and 6 deletions.
61 changes: 61 additions & 0 deletions packages/datastore/__tests__/DataStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -528,6 +528,67 @@ describe('DataStore tests', () => {
expect(patches2).toMatchObject(expectedPatches2);
});

test('Read-only fields cannot be overwritten', async () => {
let model: Model;
const save = jest.fn(() => [model]);
const query = jest.fn(() => [model]);

jest.resetModules();
jest.doMock('../src/storage/storage', () => {
const mock = jest.fn().mockImplementation(() => {
const _mock = {
init: jest.fn(),
save,
query,
runExclusive: jest.fn(fn => fn.bind(this, _mock)()),
};

return _mock;
});

(<any>mock).getNamespace = () => ({ models: {} });

return { ExclusiveStorage: mock };
});

({ initSchema, DataStore } = require('../src/datastore/datastore'));

const classes = initSchema(testSchema());

const { Model } = classes as { Model: PersistentModelConstructor<Model> };

model = new Model({
field1: 'something',
dateCreated: new Date().toISOString(),
createdAt: '2021-06-03T20:56:23.201Z',
} as any);

await expect(DataStore.save(model)).rejects.toThrowError(
'createdAt is read-only.'
);

model = new Model({
field1: 'something',
dateCreated: new Date().toISOString(),
});

model = Model.copyOf(model, draft => {
(draft as any).createdAt = '2021-06-03T20:56:23.201Z';
});

await expect(DataStore.save(model)).rejects.toThrowError(
'createdAt is read-only.'
);

model = Model.copyOf(model, draft => {
(draft as any).updatedAt = '2021-06-03T20:56:23.201Z';
});

await expect(DataStore.save(model)).rejects.toThrowError(
'updatedAt is read-only.'
);
});

test('Instantiation validations', async () => {
expect(() => {
new Model({
Expand Down
18 changes: 18 additions & 0 deletions packages/datastore/__tests__/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ export declare class Model {
public readonly emails?: string[];
public readonly ips?: (string | null)[];
public readonly metadata?: Metadata;
public readonly createdAt?: string;
public readonly updatedAt?: string;

constructor(init: ModelInit<Model>);

Expand Down Expand Up @@ -136,6 +138,22 @@ export function testSchema(): Schema {
isRequired: false,
attributes: [],
},
createdAt: {
name: 'createdAt',
isArray: false,
type: 'AWSDateTime',
isRequired: false,
attributes: [],
isReadOnly: true,
},
updatedAt: {
name: 'updatedAt',
isArray: false,
type: 'AWSDateTime',
isRequired: false,
attributes: [],
isReadOnly: true,
},
},
},
Post: {
Expand Down
46 changes: 45 additions & 1 deletion packages/datastore/src/datastore/datastore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ import {
ErrorHandler,
SyncExpression,
AuthModeStrategyType,
ModelFields,
} from '../types';
import {
DATASTORE,
Expand Down Expand Up @@ -406,7 +407,7 @@ const createModelClass = <T extends PersistentModel>(
const model = produce(
source,
draft => {
fn(<MutableModel<T>>draft);
fn(<MutableModel<T>>(draft as unknown));
draft.id = source.id;
const modelValidator = validateModelFields(modelDefinition);
Object.entries(draft).forEach(([k, v]) => {
Expand Down Expand Up @@ -813,6 +814,9 @@ class DataStore {

const modelDefinition = getModelDefinition(modelConstructor);

// ensuring "read-only" data isn't being overwritten
this.checkReadOnlyProperty(modelDefinition.fields, model, patchesTuple);

const producedCondition = ModelPredicateCreator.createFromExisting(
modelDefinition,
condition
Expand All @@ -830,6 +834,46 @@ class DataStore {
return savedModel;
};

private checkReadOnlyProperty(
fields: ModelFields,
model: Record<string, any>,
patchesTuple: [
Patch[],
Readonly<
{
id: string;
} & Record<string, any>
>
]
) {
if (!patchesTuple) {
// saving a new model instance
const modelKeys = Object.keys(model);
modelKeys.forEach(key => {
if (fields[key] && fields[key].isReadOnly) {
throw new Error(`${key} is read-only.`);
}
});
} else {
// * Updating an existing instance via 'patchesTuple'
// patchesTuple[0] is an object that contains the info we need
// like the 'path' (mapped to the model's key) and the 'value' of the patch
const patchArray = patchesTuple[0].map(p => [p.path[0], p.value]);
patchArray.forEach(patch => {
const [key, val] = [...patch];

// the value of a read-only field should be undefined - if so, no need to do the following check
if (!val || !fields[key]) return;

// if the value is NOT undefined, we have to check the 'isReadOnly' property
// and throw an error to avoid persisting a mutation
if (fields[key].isReadOnly) {
throw new Error(`${key} is read-only.`);
}
});
}
}

setConflictHandler = (config: DataStoreConfig): ConflictHandler => {
const { DataStore: configDataStore } = config;

Expand Down
35 changes: 30 additions & 5 deletions packages/datastore/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,7 @@ type ModelField = {
| EnumFieldType;
isArray: boolean;
isRequired?: boolean;
isReadOnly?: boolean;
isArrayNullable?: boolean;
association?: ModelAssociation;
attributes?: ModelAttributes[];
Expand All @@ -299,24 +300,48 @@ export type NonModelTypeConstructor<T> = {
};

// Class for model
export type PersistentModelConstructor<T extends PersistentModel> = {
new (init: ModelInit<T>): T;
copyOf(src: T, mutator: (draft: MutableModel<T>) => void): T;
export type PersistentModelConstructor<
T extends PersistentModel,
K extends PersistentModelMetaData = {
readOnlyFields: 'createdAt' | 'updatedAt';
}
> = {
new (init: ModelInit<T, K>): T;
copyOf(src: T, mutator: (draft: MutableModel<T, K>) => void): T;
};

export type TypeConstructorMap = Record<
string,
PersistentModelConstructor<any> | NonModelTypeConstructor<any>
>;

// Instance of model
export type PersistentModelMetaData = {
readOnlyFields: string;
};

export type PersistentModel = Readonly<{ id: string } & Record<string, any>>;
export type ModelInit<T> = Omit<T, 'id'>;
export type ModelInit<
T,
K extends PersistentModelMetaData = {
readOnlyFields: 'createdAt' | 'updatedAt';
}
> = Omit<T, 'id' | K['readOnlyFields']>;
type DeepWritable<T> = {
-readonly [P in keyof T]: T[P] extends TypeName<T[P]>
? T[P]
: DeepWritable<T[P]>;
};
export type MutableModel<T> = Omit<DeepWritable<T>, 'id'>;

export type MutableModel<
T extends Record<string, any>,
K extends PersistentModelMetaData = {
readOnlyFields: 'createdAt' | 'updatedAt';
}
// This provides Intellisense with ALL of the properties, regardless of read-only
// but will throw a linting error if trying to overwrite a read-only property
> = DeepWritable<Omit<T, 'id' | K['readOnlyFields']>> &
Readonly<Pick<T, 'id' | K['readOnlyFields']>>;

export type ModelInstanceMetadata = {
id: string;
Expand Down

0 comments on commit 10857d5

Please sign in to comment.