Skip to content

Commit

Permalink
Merge pull request #7 from JaapRood/feature/deep-merge
Browse files Browse the repository at this point in the history
Add mergeDeep to State, Models and Iterable Schemas
  • Loading branch information
JaapRood authored May 3, 2018
2 parents 2275208 + 2db2d3f commit 8ae17d5
Show file tree
Hide file tree
Showing 5 changed files with 245 additions and 11 deletions.
66 changes: 63 additions & 3 deletions src/merge.js
Original file line number Diff line number Diff line change
@@ -1,27 +1,87 @@
const Invariant = require('invariant')
const Immutable = require('immutable')
const _assign = require('lodash.assign')
const _isFunction = require('lodash.isfunction')
const _isPlainObject = require('lodash.isplainobject')
const _keys = require('lodash.keys')

const Schema = require('./schema')
const Props = require('./props')

const internals = {}

exports.create = function(identity, factory) {
exports.create = function(identity, factory, schema={}) {
return {
merge: internals.merge.bind(_assign({}, identity, factory))
merge: internals.merge.bind(_assign({}, identity, factory)),
mergeDeep: internals.mergeDeep.bind(_assign({}, identity, factory, schema))
}
}

internals.merge = function(state, data) {
Invariant(this.instanceOf(state), `Instance of ${this.typeName()} is required to merge it with new attributes`)
Invariant(Immutable.Iterable.isIterable(data) || _isPlainObject(data), 'Plain object or Immutable Iterable required as source to merge with the state instance')

return state.merge(internals.mergableInstance.call(this, data));
}

internals.mergeDeep = function(state, data) {
Invariant(this.instanceOf(state), `Instance of ${this.typeName()} is required to merge deep it with new attributes`)
Invariant(Immutable.Iterable.isIterable(data) || _isPlainObject(data), 'Plain object or Immutable Iterable required as source to merge deep with the state instance')



const mergeDeep = function(current, next, schema) {
// merge schema props shallowly, the rest deeply
const withSchema = next.filter((modelValue, modelProp) => {
if (!schema) return false

const definition = Schema.getDefinition(schema, modelProp)

return definition && (Schema.isType(definition) || Schema.isSchema(definition))
})
const withoutSchema = next.filter((modelValue, modelProp) => {
return !withSchema.has(modelProp)
})

return current.mergeDeep(withoutSchema).mergeWith(function(currentValue, nextValue, modelProp) {
const definition = Schema.getDefinition(schema, modelProp)

if (Schema.isType(definition)) {
let type = definition

if (!_isFunction(type.mergeDeep)) {
return nextValue
} else {
return type.mergeDeep(currentValue, nextValue)
}
} else if (Schema.isSchema(definition)) {
let nestedSchema = definition

if (nestedSchema.getItemSchema && _isFunction(nestedSchema.mergeDeep)) { // could be an Iterable schema
return nestedSchema.mergeDeep(currentValue, nextValue)
} else if (
Immutable.Iterable.isKeyed(currentValue) && _isPlainObject(nestedSchema)
) {
return mergeDeep(currentValue, nextValue, nestedSchema)
}
}

// if we've arrived here, we didn't find a way to merge, so return the next value
return nextValue
}, withSchema)
}

const mergableInstance = internals.mergableInstance.call(this, data)
const modelSchema = this.schema && this.schema()

return mergeDeep(state, mergableInstance, modelSchema)
}

internals.mergableInstance = function(data) {
if (!this.instanceOf(data)) {
let dataKeys = Immutable.Seq(Immutable.Iterable.isIterable(data) ? data.keys() : _keys(data))
data = this.factory(data).filter((val, key) => dataKeys.includes(key))
}

return state.merge(data.remove(Props.cid).remove(Props.name));
return data.remove(Props.cid).remove(Props.name)
}
15 changes: 8 additions & 7 deletions src/model.js
Original file line number Diff line number Diff line change
Expand Up @@ -44,24 +44,25 @@ exports.create = (spec) => {
defaults
} = spec

let schema = spec.schema

Invariant(!defaults || Factory.isDefaults(defaults), 'When specifying defaults in the model spec it must be a plain object or Immutable Iterable')
Invariant(!schema || Schema.isSchema(schema), 'When specificying a schema for a model, it must be a valid schema definition')
Invariant(!spec.schema || Schema.isSchema(spec.schema), 'When specificying a schema for a model, it must be a valid schema definition')

if (!schema) schema = {}
if (!spec.schema) spec.schema = {}

const identity = Identity.create(typeName)
const factory = Factory.create(defaults)
const merge = Merge.create(identity, factory)
const schema = {
schema: () => spec.schema
}
const merge = Merge.create(identity, factory, schema)

const modelPrototype = _assign(
Object.create(internals.Model.prototype), // makes `x instanceof Model` work
identity,
factory,
merge,
schema,
{
schema: () => schema,
parse: internals.parse,
serialize: internals.serialize
}
Expand Down Expand Up @@ -161,7 +162,7 @@ internals.serialize = function(model, options) {
} else if (
Immutable.Iterable.isIndexed(modelValue) && _isArray(nestedSchema) ||
Immutable.Iterable.isKeyed(modelValue) && _isPlainObject(nestedSchema)
) {
) {
return this.serialize(modelValue, _assign({}, options, { schema: nestedSchema }))
}
}
Expand Down
6 changes: 5 additions & 1 deletion src/schema.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ internals.IterableSchema = function(iterable, itemSchema) {
serialize: (val, ...otherArgs) => {
const serialize = itemSchema.serialize || internals.idenity
return val.map((item) => serialize(item, ...otherArgs)).toJS()
},
mergeDeep: (current, next) => {
return iterable(next)
}
})
}
Expand Down Expand Up @@ -55,7 +58,8 @@ exports.isIterableSchema = function(maybeSchema) {
exports.isType = function(maybeType) {
return !!(maybeType && (
_isFunction(maybeType.factory) ||
_isFunction(maybeType.serialize)
_isFunction(maybeType.serialize) ||
_isFunction(maybeType.mergeDeep)
))
}

Expand Down
111 changes: 111 additions & 0 deletions test/model.js
Original file line number Diff line number Diff line change
Expand Up @@ -419,3 +419,114 @@ Test('model.merge', function(t) {
}, 'accepts a base and source instance with a different state type')
})

Test('model.mergeDeep', function(t) {
t.plan(2 + 8)

const OtherModel = Model.create({
typeName: 'woo'
})


const existingA = 'a'
const existingB = 'b'
const existingC = 'c'

const inputA = '1';
const inputB = '2';

const outputA = 'a1';
const outputB = 'b2';
const outputC = 'c3';

const schema = {
a: {
mergeDeep(existing, value, options) {
t.equal(existing, existingA, 'mergeDeep of type is called with the current value as first argument')
t.equal(value, inputA, 'mergeDeep of type is called with the to be merged value as the second argument')

return existing + value
}
},
nested: {
b: {
mergeDeep: (existing, value) => existing + value
}
},
multiple: [{
mergeDeep: () => outputC
}],
nestedModel: OtherModel,
notInstance: {
mergeDeep: (existing, value) => existing + value,
instanceOf() { return false }
},
notIncluded: {
mergeDeep() {
t.fail('not included properties should not be called')
}
},

nestedList: Schema.listOf({
factory: (val) => val
}),

nestedSet: Schema.setOf({
factory: (val) => val
}),

nestedOrderedSet: Schema.orderedSetOf({
factory: (val) => val
})
}

const TestModel = Model.create({
typeName: 'test-model',
schema: schema
})

const existing = TestModel.factory({
a: existingA,
nested: {
b: existingB
},
nestedModel: {
'a': existingA,
'b': existingB
},
notInstance: existingA,
notTouched: existingA,

multiple: ['values', 'array'],

nestedList: [existingA, existingB],
nestedSet: [existingA, existingB],
nestedOrderedSet: [existingC, existingB, existingA]
})

t.doesNotThrow(function() {
const merged = TestModel.mergeDeep(existing, {
a: inputA,
nested: {
b: inputB
},
nestedModel: {
'a': 'aaa',
'b': 'bbb'
},
notInstance: inputA,
nestedList: [outputA, outputB],
nestedSet: [outputA, outputB],
nestedOrderedSet: [outputC, outputB, outputA]
})

t.equal(merged.get('a'), outputA, 'value returned by mergeDeep of schema is used as value')
t.equal(merged.getIn(['nested', 'b']), outputB, 'nested schema is applied to nested attributes')
t.ok(OtherModel.instanceOf(merged.get('nestedModel')), 'Model definitions are valid type definitions for merging deep')
t.equal(merged.get('notInstance'), outputA, 'mergeDeep of schema definition is still applied when schema has instanceOf method that returns falsey')

t.ok(Immutable.List([outputA, outputB]).equals(merged.get('nestedList')), 'schema generated with `Schema.listOf` return only the new values as Lists merging is ambiguous')
t.ok(Immutable.Set([outputA, outputB]).equals(merged.get('nestedSet')), 'schema generated with `Schema.setOf` return only then new values as Sets merging is ambiguous')
t.ok(Immutable.OrderedSet([outputC, outputB, outputA]).equals(merged.get('nestedOrderedSet')), 'schema generated with `Schema.orderedSetOf` return only then ew value as OrderedSets merging is ambiguous')
}, 'accepts a Model instance and a mergable data')
})

58 changes: 58 additions & 0 deletions test/state.js
Original file line number Diff line number Diff line change
Expand Up @@ -328,3 +328,61 @@ Test('state.merge', function(t) {
t.ok(TestState.instanceOf(mergedInstance), 'returns an updated instance of the type of the base')
}, 'accepts a base and source instance with a different state type')
})

Test('state.mergeDeep', function(t) {
t.plan(3 + 4 + 2 + 2)

const rawDefaults = {
c: 1
}

const TestState = State.create('test-state', rawDefaults)
const OtherState = State.create('other-state', rawDefaults)

const baseInstance = TestState.factory({
a: 1,
c: 3,
d: {
e: 5
}
})

const rawSource = {
a: 2,
b: 3,
d: {
f: 6
}
}

const sourceInstance = TestState.factory(rawSource)
const otherInstance = OtherState.factory(rawSource)

t.doesNotThrow(function() {
const mergedInstance = TestState.mergeDeep(baseInstance, rawSource)

t.ok(TestState.instanceOf(mergedInstance), 'returns an updated instance')
t.ok(mergedInstance.equals(baseInstance.mergeDeep(rawSource)), 'updated instance has attributes of source deep merged into instance')
}, 'accepts a State instance and a plain object of new attributes')

t.doesNotThrow(function() {
const mergedInstance = TestState.mergeDeep(baseInstance, sourceInstance)

t.ok(TestState.instanceOf(mergedInstance), 'returns an updated instance')
t.ok(mergedInstance.equals(baseInstance.mergeDeep(rawDefaults, rawSource)), 'updated instance has attributes of source deep merged into base')
t.equals(mergedInstance.get('__cid'), baseInstance.get('__cid'), 'updated instance has client identifer `__cid` from base')
}, 'accepts two State instances, a base and source')

t.doesNotThrow(function() {
const withContext = TestState.mergeDeep(baseInstance, sourceInstance)
const withoutContext = TestState.mergeDeep.call(null, baseInstance, sourceInstance)

t.ok(withContext.equals(withoutContext), 'returns the same when called out of context')
}, 'can be called without context')

t.doesNotThrow(function() {
const mergedInstance = TestState.mergeDeep(baseInstance, otherInstance)

t.ok(TestState.instanceOf(mergedInstance), 'returns an updated instance of the type of the base')
}, 'accepts a base and source instance with a different state type')
})

0 comments on commit 8ae17d5

Please sign in to comment.