Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: merge patches for consecutive copyOf #9936

Merged
merged 4 commits into from
Jun 13, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 74 additions & 3 deletions packages/datastore/__tests__/DataStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -862,6 +862,77 @@ describe('DataStore tests', () => {
expect(model2.optionalField1).toBeNull();
});

test('multiple copyOf operations carry all changes on save', 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> };

const model1 = new Model({
dateCreated: new Date().toISOString(),
field1: 'original',
optionalField1: 'original',
});
model = model1;

await DataStore.save(model1);

const model2 = Model.copyOf(model1, draft => {
(<any>draft).field1 = 'field1Change1';
(<any>draft).optionalField1 = 'optionalField1Change1';
});

const model3 = Model.copyOf(model2, draft => {
(<any>draft).field1 = 'field1Change2';
});
model = model3;

await DataStore.save(model3);

const [settingsSave, saveOriginalModel, saveModel3] = <any>(
save.mock.calls
);

const [_model, _condition, _mutator, [patches]] = saveModel3;

const expectedPatches = [
{
op: 'replace',
path: ['field1'],
value: 'field1Change2',
},
{
op: 'replace',
path: ['optionalField1'],
value: 'optionalField1Change1',
},
];
expect(patches).toMatchObject(expectedPatches);
});

test('Non @model - Field cannot be changed', () => {
const { Metadata } = initSchema(testSchema()) as {
Metadata: NonModelTypeConstructor<Metadata>;
Expand Down Expand Up @@ -1109,9 +1180,9 @@ describe('DataStore tests', () => {

const expectedPatches2 = [
{
op: 'add',
path: ['emails', 3],
value: 'joe@doe.com',
op: 'replace',
path: ['emails'],
value: ['john@doe.com', 'jane@doe.com', 'joe@doe.com', 'joe@doe.com'],
},
];

Expand Down
126 changes: 126 additions & 0 deletions packages/datastore/__tests__/util.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { enablePatches, produce, Patch } from 'immer';
import {
isAWSDate,
isAWSDateTime,
Expand All @@ -11,6 +12,7 @@ import {
validatePredicateField,
valuesEqual,
processCompositeKeys,
mergePatches,
} from '../src/util';

describe('datastore util', () => {
Expand Down Expand Up @@ -592,4 +594,128 @@ describe('datastore util', () => {
expect(isAWSIPAddress(test)).toBe(false);
});
});

describe('mergePatches', () => {
enablePatches();
test('merge patches with no conflict', () => {
const modelA = {
foo: 'originalFoo',
bar: 'originalBar',
};
let patchesAB;
let patchesBC;
const modelB = produce(
modelA,
draft => {
draft.foo = 'newFoo';
},
patches => {
patchesAB = patches;
}
);
const modelC = produce(
modelB,
draft => {
draft.bar = 'newBar';
},
patches => {
patchesBC = patches;
}
);

const mergedPatches = mergePatches(modelA, patchesAB, patchesBC);
expect(mergedPatches).toEqual([
{
op: 'replace',
path: ['foo'],
value: 'newFoo',
},
{
op: 'replace',
path: ['bar'],
value: 'newBar',
},
]);
});
test('merge patches with conflict', () => {
const modelA = {
foo: 'originalFoo',
bar: 'originalBar',
};
let patchesAB;
let patchesBC;
const modelB = produce(
modelA,
draft => {
draft.foo = 'newFoo';
draft.bar = 'newBar';
},
patches => {
patchesAB = patches;
}
);
const modelC = produce(
modelB,
draft => {
draft.bar = 'newestBar';
},
patches => {
patchesBC = patches;
}
);

const mergedPatches = mergePatches(modelA, patchesAB, patchesBC);
expect(mergedPatches).toEqual([
{
op: 'replace',
path: ['foo'],
value: 'newFoo',
},
{
op: 'replace',
path: ['bar'],
value: 'newestBar',
},
]);
});
test('merge patches with conflict - list', () => {
const modelA = {
foo: [1, 2, 3],
};
let patchesAB;
let patchesBC;
const modelB = produce(
modelA,
draft => {
draft.foo.push(4);
},
patches => {
patchesAB = patches;
}
);
const modelC = produce(
modelB,
draft => {
draft.foo.push(5);
},
patches => {
patchesBC = patches;
}
);

const mergedPatches = mergePatches(modelA, patchesAB, patchesBC);
expect(mergedPatches).toEqual([
{
op: 'add',
path: ['foo', 3],
value: 4,
},
{
op: 'add',
path: ['foo', 4],
value: 5,
},
]);
});
});
});
19 changes: 16 additions & 3 deletions packages/datastore/src/datastore/datastore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ import {
sortCompareFunction,
DeferredCallbackResolver,
validatePredicate,
mergePatches,
} from '../util';

setAutoFreeze(true);
Expand Down Expand Up @@ -484,9 +485,21 @@ const createModelClass = <T extends PersistentModel>(
p => (patches = p)
);

if (patches.length) {
modelPatchesMap.set(model, [patches, source]);
checkReadOnlyPropertyOnUpdate(patches, modelDefinition);
const hasExistingPatches = modelPatchesMap.has(source);
if (patches.length || hasExistingPatches) {
if (hasExistingPatches) {
const [existingPatches, existingSource] = modelPatchesMap.get(source);
const mergedPatches = mergePatches(
existingSource,
existingPatches,
patches
);
modelPatchesMap.set(model, [mergedPatches, existingSource]);
checkReadOnlyPropertyOnUpdate(mergedPatches, modelDefinition);
} else {
modelPatchesMap.set(model, [patches, source]);
checkReadOnlyPropertyOnUpdate(patches, modelDefinition);
}
}

return model;
Expand Down
43 changes: 40 additions & 3 deletions packages/datastore/src/util.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Buffer } from 'buffer';
import { monotonicFactory, ULID } from 'ulid';
import { v4 as uuid } from 'uuid';
import { produce, applyPatches, Patch } from 'immer';
import { ModelInstanceCreator } from './datastore/datastore';
import {
AllOperators,
Expand Down Expand Up @@ -146,7 +147,7 @@ export const isNonModelConstructor = (
return nonModelClasses.has(obj);
};

/*
/*
When we have GSI(s) with composite sort keys defined on a model
There are some very particular rules regarding which fields must be included in the update mutation input
The field selection becomes more complex as the number of GSIs with composite sort keys grows
Expand All @@ -156,7 +157,7 @@ export const isNonModelConstructor = (
2. all of the fields from any other composite sort key that intersect with the fields from 1.

E.g.,
Model @model
Model @model
@key(name: 'key1' fields: ['hk', 'a', 'b', 'c'])
@key(name: 'key2' fields: ['hk', 'a', 'b', 'd'])
@key(name: 'key3' fields: ['hk', 'x', 'y', 'z'])
Expand Down Expand Up @@ -192,7 +193,7 @@ export const processCompositeKeys = (
.filter(isModelAttributeCompositeKey)
.map(extractCompositeSortKey);

/*
/*
if 2 sets of fields have any intersecting fields => combine them into 1 union set
e.g., ['a', 'b', 'c'] and ['a', 'b', 'd'] => ['a', 'b', 'c', 'd']
*/
Expand Down Expand Up @@ -773,3 +774,39 @@ export class DeferredCallbackResolver {
this.limitPromise.resolve(LimitTimerRaceResolvedValues.LIMIT);
}
}

/**
* merge two sets of patches created by immer produce.
* newPatches take precedent over oldPatches for patches modifying the same path.
* In the case many consecutive pathces are merged the original model should
* always be the root model.
*
* Example:
* A -> B, patches1
* B -> C, patches2
*
* mergePatches(A, patches1, patches2) to get patches for A -> C
*
* @param originalSource the original Model the patches should be applied to
* @param oldPatches immer produce patch list
* @param newPatches immer produce patch list (will take precedence)
* @return merged patches
*/
Comment on lines +778 to +794
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for this!

export function mergePatches<T>(
originalSource: T,
oldPatches: Patch[],
newPatches: Patch[]
): Patch[] {
const patchesToMerge = oldPatches.concat(newPatches);
let patches: Patch[];
produce(
originalSource,
draft => {
applyPatches(draft, patchesToMerge);
},
p => {
patches = p;
}
);
return patches;
}