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

Added option to allow merging invalid changesets #74

Merged
merged 15 commits into from
Jul 22, 2016
Merged
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -380,20 +380,24 @@ changeset.rollback(); // returns changeset

#### `merge`

Merges 2 valid changesets and returns a new changeset with the same underlying content and validator as the origin. Both changesets must point to the same underlying object. For example:
Merges 2 changesets and returns a new changeset with the same underlying content and validator as the origin. Both changesets must point to the same underlying object. For example:

```js
let changesetA = new Changeset(user, validatorFn);
let changesetB = new Changeset(user, validatorFn);
changesetA.set('firstName', 'Jim');
changesetA.get('errors') // [{key: 'firstName', value: 'Jim', validations: ['Firstname cannot be Jim, sorry Jim']}]
Copy link
Collaborator

Choose a reason for hiding this comment

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

Nitpick: prefer having spaces within {}, e.g. [{ key: ... }]

Copy link
Collaborator

Choose a reason for hiding this comment

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

I'm not sure this addition helps with understanding how merge works, feels like it could be confusing

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Cleaned that up

changesetB.set('firstName', 'Jimmy');
changesetB.get('errors') // []
changesetB.set('lastName', 'Fallon');
let changesetC = changesetA.merge(changesetB);
changesetC.execute();
user.get('firstName'); // "Jimmy"
user.get('lastName'); // "Fallon"
```

When merging, the changeset being merged in takes precedence. For example: if `B` contains errors, the errors from `B` will be merged, and the invalid attributes on `B` will not be merged from `A` even if they're valid on `A`. Likewise, if `A` contains errors on attributes which are valid on `B`, the value from `B` will be used and the errors for those keys will not be merged from `A`. This is to prevent the new changeset from containing `changes` and `errors` on the same keys, which is a guarantee of a normal changeset.
Copy link
Collaborator

Choose a reason for hiding this comment

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

This is still confusing, in fact I think it would be better to just remove all the changes to the readme and only keep the change on L383. I feel like this is just implementation detail that the end user doesn't need to know about.


Note that both changesets `A` and `B` are not destroyed by the merge, so you might want to call `destroy()` on them to avoid memory leaks.

**[⬆️ back to top](#api)**
Expand Down
17 changes: 14 additions & 3 deletions addon/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import isEmptyObject from 'ember-changeset/utils/computed/is-empty-object';
import isPromise from 'ember-changeset/utils/is-promise';
import isObject from 'ember-changeset/utils/is-object';
import pureAssign from 'ember-changeset/utils/assign';
import objectWithout from 'ember-changeset/utils/object-without';
import { CHANGESET, isChangeset } from 'ember-changeset/-private/internals';

const {
Expand Down Expand Up @@ -211,18 +212,28 @@ export function changeset(obj, validateFn = defaultValidatorFn, validationMap =
let content = get(this, CONTENT);
assert('Cannot merge with a non-changeset', isChangeset(changeset));
assert('Cannot merge with a changeset of different content', get(changeset, CONTENT) === content);
assert('Cannot merge invalid changesets', get(this, 'isValid') && get(changeset, 'isValid'));

if (get(this, 'isPristine') && get(changeset, 'isPristine')) {
return this;
}

let changesA = get(this, CHANGES);
let changesB = get(changeset, CHANGES);
let mergedChanges = pureAssign(changesA, changesB);

Copy link
Collaborator

Choose a reason for hiding this comment

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

No whitelines between the variable declarations please!

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Do you think you could add a jcscrc or eslint config with the rules you're after?

Copy link
Collaborator

Choose a reason for hiding this comment

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

Yeah I will at some point

let errorsA = get(this, ERRORS);
let errorsB = get(changeset, ERRORS);

let newChangeset = new Changeset(content, get(this, VALIDATOR));

let newErrors = objectWithout(keys(changesB), errorsA);
let newChanges = objectWithout(keys(errorsB), changesA);

let mergedChanges = pureAssign(newChanges, changesB);
let mergedErrors = pureAssign(newErrors, errorsB);

newChangeset[CHANGES] = mergedChanges;
newChangeset.notifyPropertyChange(CHANGES);
newChangeset[ERRORS] = mergedErrors;
newChangeset._notifyVirtualProperties();

return newChangeset;
},
Expand Down
18 changes: 18 additions & 0 deletions addon/utils/object-without.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
const { keys } = Object;

/**
* Merges all sources together, excluding keys in excludedKeys.
*
* @param {Array[String]} excludedKeys
* @param {...Object} sources
*
* @return {Object}
*/
export default function objectWithout(excludedKeys, ...sources) {
return sources.reduce((acc, source) => {
keys(source)
.filter((key) => excludedKeys.indexOf(key) === -1 || !source.hasOwnProperty(key))
.forEach((key) => acc[key] = source[key]);
return acc;
}, {});
}
1 change: 1 addition & 0 deletions app/utils/object-without.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default } from 'ember-changeset/utils/object-without';
22 changes: 17 additions & 5 deletions tests/unit/changeset-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -227,15 +227,27 @@ test('#merge merges 2 valid changesets', function(assert) {
assert.deepEqual(get(dummyChangesetB, 'changes'), [{ key: 'lastName', value: 'Bob' }], 'should not mutate second changeset');
});

test('#merge does not merge invalid changesets', function(assert) {
test('#merge merges invalid changesets', function(assert) {
let dummyChangesetA = new Changeset(dummyModel, dummyValidator);
let dummyChangesetB = new Changeset(dummyModel, dummyValidator);
let dummyChangesetC = new Changeset(dummyModel, dummyValidator);
dummyChangesetA.set('age', 21);
dummyChangesetA.set('name', 'a');
dummyChangesetB.set('name', 'b');
dummyChangesetB.set('name', 'Tony Stark');
dummyChangesetC.set('name', 'b');

assert.throws(() => dummyChangesetA.merge(dummyChangesetB), ({ message }) => {
return message === 'Assertion Failed: Cannot merge invalid changesets';
}, 'should throw error');
let dummyChangesetD = dummyChangesetA.merge(dummyChangesetB);
dummyChangesetD = dummyChangesetD.merge(dummyChangesetC);

let expectedChanges = [{ key: 'age', value: 21 }];
let expectedErrors = [{ key: 'name', 'validation': 'too short', value: 'b' }];

assert.deepEqual(get(dummyChangesetA, 'isInvalid'), true, 'changesetA is not valid becuase of name');
assert.deepEqual(get(dummyChangesetB, 'isValid'), true, 'changesetB should be invalid');
assert.deepEqual(get(dummyChangesetC, 'isInvalid'), true, 'changesetC should be invalid');
assert.deepEqual(get(dummyChangesetD, 'isInvalid'), true, 'changesetD should be invalid');
assert.deepEqual(get(dummyChangesetD, 'changes'), expectedChanges, 'should not merge invalid changes');
assert.deepEqual(get(dummyChangesetD, 'errors'), expectedErrors, 'should assign errors from both changesets');
});

test('#merge does not merge a changeset with a non-changeset', function(assert) {
Expand Down
15 changes: 15 additions & 0 deletions tests/unit/utils/object-without-test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import objectWithout from 'dummy/utils/object-without';
import { module, test } from 'qunit';

module('Unit | Utility | object without');

test('it exludes the given keys from all merged objects', function(assert) {
let objA = { name: 'Ivan' };
let objB = { name: 'John' };
let objC = { age: 27 };
let objD = objectWithout([ 'age' ], objA, objB, objC);

assert.deepEqual(objD, { name: 'John' }, 'result only contains name');
assert.deepEqual(objA.name, 'Ivan', 'does not mutate original object');
assert.deepEqual(objC.age, 27, 'does not mutate original object');
});