diff --git a/lib/model-builder.js b/lib/model-builder.js index 7ce0ec45..4a8aaee4 100644 --- a/lib/model-builder.js +++ b/lib/model-builder.js @@ -17,7 +17,9 @@ var deprecated = require('depd')('loopback-datasource-juggler'); var DefaultModelBaseClass = require('./model.js'); var List = require('./list.js'); var ModelDefinition = require('./model-definition.js'); -var mergeSettings = require('./utils').mergeSettings; +var deepMerge = require('./utils').deepMerge; +var deepMergeProperty = require('./utils').deepMergeProperty; +var rankArrayElements = require('./utils').rankArrayElements; var MixinProvider = require('./mixins'); // Set up types @@ -162,6 +164,15 @@ ModelBuilder.prototype.define = function defineClass(className, properties, sett } } + // Assert current model's base class provides method `getMergePolicy()`. + assert(ModelBaseClass.getMergePolicy, `Base class ${ModelBaseClass.modelName} + does not provide method getMergePolicy(). Most likely it is not inheriting + from datasource-juggler's built-in default ModelBaseClass, which is an + incorrect usage of the framework.`); + + // Initialize base model inheritance rank if not set already + ModelBaseClass.__rank = ModelBaseClass.__rank || 1; + // Make sure base properties are inherited // See https://github.com/strongloop/loopback-datasource-juggler/issues/293 if ((parent && !settings.base) || (!parent && settings.base)) { @@ -197,6 +208,9 @@ ModelBuilder.prototype.define = function defineClass(className, properties, sett hiddenProperty(ModelClass, 'modelName', className); } + // Iterate sub model inheritance rank over base model rank + ModelClass.__rank = ModelBaseClass.__rank + 1; + util.inherits(ModelClass, ModelBaseClass); // store class in model pool @@ -348,59 +362,61 @@ ModelBuilder.prototype.define = function defineClass(className, properties, sett * ``` * * @param {String} className Name of the new model being defined. - * @options {Object} properties Properties to define for the model, added to properties of model being extended. - * @options {Object} settings Model settings, such as relations and acls. - * + * @options {Object} subClassProperties child model properties, added to base model + * properties. + * @options {Object} subClassSettings child model settings such as relations and acls, + * merged with base model settings. */ - ModelClass.extend = function(className, subclassProperties, subclassSettings) { - var properties = ModelClass.definition.properties; - var settings = ModelClass.definition.settings; + ModelClass.extend = function(className, subClassProperties, subClassSettings) { + var baseClassProperties = ModelClass.definition.properties; + var baseClassSettings = ModelClass.definition.settings; - subclassProperties = subclassProperties || {}; - subclassSettings = subclassSettings || {}; + subClassProperties = subClassProperties || {}; + subClassSettings = subClassSettings || {}; // Check if subclass redefines the ids var idFound = false; - for (var k in subclassProperties) { - if (subclassProperties[k] && subclassProperties[k].id) { + for (var k in subClassProperties) { + if (subClassProperties[k] && subClassProperties[k].id) { idFound = true; break; } } // Merging the properties - var keys = Object.keys(properties); + var keys = Object.keys(baseClassProperties); for (var i = 0, n = keys.length; i < n; i++) { var key = keys[i]; - if (idFound && properties[key].id) { + if (idFound && baseClassProperties[key].id) { // don't inherit id properties continue; } - if (subclassProperties[key] === undefined) { - var baseProp = properties[key]; + if (subClassProperties[key] === undefined) { + var baseProp = baseClassProperties[key]; var basePropCopy = baseProp; if (baseProp && typeof baseProp === 'object') { - // Deep clone the base prop - basePropCopy = mergeSettings(null, baseProp); + // Deep clone the base properties + basePropCopy = deepMerge(baseProp); } - subclassProperties[key] = basePropCopy; + subClassProperties[key] = basePropCopy; } } - // Merge the settings - var originalSubclassSettings = subclassSettings; - subclassSettings = mergeSettings(settings, subclassSettings); + // Merging the settings + var originalSubclassSettings = subClassSettings; + let mergePolicy = ModelClass.getMergePolicy(subClassSettings); + subClassSettings = mergeSettings(baseClassSettings, subClassSettings, mergePolicy); // Ensure 'base' is not inherited. Note we don't have to delete 'super' // as that is removed from settings by modelBuilder.define and thus // it is never inherited if (!originalSubclassSettings.base) { - subclassSettings.base = ModelClass; + subClassSettings.base = ModelClass; } // Define the subclass - var subClass = modelBuilder.define(className, subclassProperties, subclassSettings, ModelClass); + var subClass = modelBuilder.define(className, subClassProperties, subClassSettings, ModelClass); // Calling the setup function if (typeof subClass.setup === 'function') { @@ -410,6 +426,92 @@ ModelBuilder.prototype.define = function defineClass(className, properties, sett return subClass; }; + /* + * Merge parent and child model settings according to the provided merge policy. + * + * Below is presented the expected merge behaviour for each option of the policy. + * NOTE: This applies to top-level settings properties + * + * - Any + * - `{replace: true}` (default): child replaces the value from parent + * - assignin `null` on child setting deletes the inherited setting + * + * - Arrays: + * - `{replace: false}`: unique elements of parent and child cumulate + * - `{rank: true}` adds the model inheritance rank to array + * elements of type Object {} as internal property `__rank` + * + * - Object {}: + * - `{replace: false}`: deep merges parent and child objects + * - `{patch: true}`: child replaces inner properties from parent + * + * Here is an example of merge policy: + * ``` + * { + * description: {replace: true}, // string or array + * properties: {patch: true}, // object + * hidden: {replace: false}, // array + * protected: {replace: false}, // array + * relations: {acls: true}, // object + * acls: {rank: true}, // array + * } + * ``` + * + * @param {Object} baseClassSettings parent model settings. + * @param {Object} subClassSettings child model settings. + * @param {Object} mergePolicy merge policy, as defined in `ModelClass.getMergePolicy()` + * @return {Object} mergedSettings merged parent and child models settings. + */ + function mergeSettings(baseClassSettings, subClassSettings, mergePolicy) { + // deep clone base class settings + let mergedSettings = deepMerge(baseClassSettings); + + Object.keys(baseClassSettings).forEach(function(key) { + // rank base class settings arrays elements where required + if (mergePolicy[key] && mergePolicy[key].rank) { + baseClassSettings[key] = rankArrayElements(baseClassSettings[key], ModelBaseClass.__rank); + } + }); + + Object.keys(subClassSettings).forEach(function(key) { + // assign default merge policy to unknown settings if specified + // if none specified, a deep merge will be applied eventually + if (mergePolicy[key] == null) { // undefined or null + mergePolicy[key] = mergePolicy.__default || {}; + } + + // allow null value to remove unwanted settings from base class settings + if (subClassSettings[key] === mergePolicy.__delete) { + delete mergedSettings[key]; + return; + } + // rank sub class settings arrays elements where required + if (mergePolicy[key].rank) { + subClassSettings[key] = rankArrayElements(subClassSettings[key], ModelBaseClass.__rank + 1); + } + // replace base class settings where required + if (mergePolicy[key].replace) { + mergedSettings[key] = subClassSettings[key]; + return; + } + // patch inner properties of base class settings where required + if (mergePolicy[key].patch) { + // mergedSettings[key] might not be initialized + mergedSettings[key] = mergedSettings[key] || {}; + Object.keys(subClassSettings[key]).forEach(function(innerKey) { + mergedSettings[key][innerKey] = subClassSettings[key][innerKey]; + }); + return; + } + + // in case no merge policy matched, apply a deep merge + // this for example handles {replace: false} and {rank: true} + mergedSettings[key] = deepMergeProperty(baseClassSettings[key], subClassSettings[key]); + }); + + return mergedSettings; + } + /** * Register a property for the model class * @param {String} propertyName Name of the property. diff --git a/lib/model.js b/lib/model.js index cc78bba8..183ef53f 100644 --- a/lib/model.js +++ b/lib/model.js @@ -674,6 +674,159 @@ ModelBaseClass.prototype.setStrict = function(strict) { this.__strict = strict; }; +/** + * + * `getMergePolicy()` provides model merge policies to apply when extending + * a child model from a base model. Such a policy drives the way parent/child model + * properties/settings are merged/mixed-in together. + * + * Below is presented the expected merge behaviour for each option. + * NOTE: This applies to top-level settings properties + * + * + * - Any + * - `{replace: true}` (default): child replaces the value from parent + * - assignin `null` on child setting deletes the inherited setting + * + * - Arrays: + * - `{replace: false}`: unique elements of parent and child cumulate + * - `{rank: true}` adds the model inheritance rank to array + * elements of type Object {} as internal property `__rank` + * + * - Object {}: + * - `{replace: false}`: deep merges parent and child objects + * - `{patch: true}`: child replaces inner properties from parent + * + * + * The recommended built-in merge policy is as follows. It is returned by getMergePolicy() + * when calling the method with option `{configureModelMerge: true}`. + * + * ``` + * { + * description: {replace: true}, // string or array + * options: {patch: true}, // object + * hidden: {replace: false}, // array + * protected: {replace: false}, // array + * indexes: {patch: true}, // object + * methods: {patch: true}, // object + * mixins: {patch: true}, // object + * relations: {patch: true}, // object + * scope: {replace: true}, // object + * scopes: {patch: true}, // object + * acls: {rank: true}, // array + * // this setting controls which child model property's value allows deleting + * // a base model's property + * __delete: null, + * // this setting controls the default merge behaviour for settings not defined + * // in the mergePolicy specification + * __default: {replace: true}, + * } + * ``` + * + * The legacy built-in merge policy is as follows, it is retuned by `getMergePolicy()` + * when avoiding option `configureModelMerge`. + * NOTE: it also provides the ACLs ranking in addition to the legacy behaviour, as well + * as fixes for settings 'description' and 'relations': matching relations from child + * replace relations from parents. + * + * ``` + * { + * description: {replace: true}, // string or array + * properties: {patch: true}, // object + * hidden: {replace: false}, // array + * protected: {replace: false}, // array + * relations: {acls: true}, // object + * acls: {rank: true}, // array + * } + * ``` + * + * + * `getMergePolicy()` can be customized using model's setting `configureModelMerge` as follows: + * + * ``` json + * { + * // .. + * options: { + * configureModelMerge: { + * // merge options + * } + * } + * // .. + * } + * ``` + * + * NOTE: mergePolicy parameter can also defined at JSON model definition root + * + * `getMergePolicy()` method can also be extended programmatically as follows: + * + * ``` js + * myModel.getMergePolicy = function(options) { + * const origin = myModel.base.getMergePolicy(options); + * return Object.assign({}, origin, { + * // new/overriding options + * }); + * }; + * ``` + * + * @param {Object} options option `configureModelMerge` can be used to alter the + * returned merge policy: + * - `configureModelMerge: true` will have the method return the recommended merge policy. + * - `configureModelMerge: {..}` will actually have the method return the provided object. + * - not providing this options will have the method return a merge policy emulating the + * the model merge behaviour up to datasource-juggler v3.6.1, as well as the ACLs ranking. + * @returns {Object} mergePolicy The model merge policy to apply when using the + * current model as base class for a child model + */ +ModelBaseClass.getMergePolicy = function(options) { + // NOTE: merge policy equivalent to datasource-juggler behaviour up to v3.6.1 + // + fix for description arrays that should not be merged + // + fix for relations that should patch matching relations + // + ranking of ACLs + var mergePolicy = { + description: {replace: true}, // string or array + properties: {patch: true}, // object + hidden: {replace: false}, // array + protected: {replace: false}, // array + relations: {patch: true}, // object + acls: {rank: true}, // array + }; + + var config = (options || {}).configureModelMerge; + + if (config === true) { + // NOTE: recommended merge policy from datasource-juggler v3.6.2 + mergePolicy = { + description: {replace: true}, // string or array + options: {patch: true}, // object + // properties: {patch: true}, // object // NOTE: not part of configurable merge + hidden: {replace: false}, // array + protected: {replace: false}, // array + indexes: {patch: true}, // object + methods: {patch: true}, // object + mixins: {patch: true}, // object + // validations: {patch: true}, // object // NOTE: not implemented + relations: {patch: true}, // object + scope: {replace: true}, // object + scopes: {patch: true}, // object + acls: {rank: true}, // array + // this option controls which value assigned on child model allows deleting + // a base model's setting + __delete: null, + // this option controls the default merge behaviour for settings not defined + // in the mergePolicy specification + __default: {replace: true}, + }; + } + + // override mergePolicy with provided model setting if required + if (config && typeof config === 'object' && !Array.isArray(config)) { + // config is an object + mergePolicy = config; + } + + return mergePolicy; +}; + // Mixin observer jutil.mixin(ModelBaseClass, require('./observer')); diff --git a/lib/utils.js b/lib/utils.js index b623e09e..c6dad85e 100644 --- a/lib/utils.js +++ b/lib/utils.js @@ -9,7 +9,8 @@ exports.fieldsToArray = fieldsToArray; exports.selectFields = selectFields; exports.removeUndefined = removeUndefined; exports.parseSettings = parseSettings; -exports.mergeSettings = exports.deepMerge = mergeSettings; +exports.mergeSettings = exports.deepMerge = deepMerge; +exports.deepMergeProperty = deepMergeProperty; exports.isPlainObject = isPlainObject; exports.defineCachedRelations = defineCachedRelations; exports.sortObjectsByIds = sortObjectsByIds; @@ -24,6 +25,7 @@ exports.idEquals = idEquals; exports.findIndexOf = findIndexOf; exports.collectTargetIds = collectTargetIds; exports.idName = idName; +exports.rankArrayElements = rankArrayElements; var g = require('strong-globalize')(); var traverse = require('traverse'); @@ -367,52 +369,64 @@ function parseSettings(urlStr) { } /** - * Merge model settings + * Objects deep merge * - * Folked from https://github.com/nrf110/deepmerge/blob/master/index.js + * Forked from https://github.com/nrf110/deepmerge/blob/master/index.js * - * The original function tries to merge array items if they are objects + * The original function tries to merge array items if they are objects, this + * was changed to always push new items in arrays, independently of their type. * - * @param {Object} target The target settings object - * @param {Object} src The source settings object - * @returns {Object} The merged settings object + * NOTE: The function operates as a deep clone when called with a single object + * argument. + * + * @param {Object} base The base object + * @param {Object} extras The object to merge with base + * @returns {Object} The merged object */ -function mergeSettings(target, src) { - var array = Array.isArray(src); +function deepMerge(base, extras) { + // deepMerge allows undefined extras to allow deep cloning of arrays + var array = Array.isArray(base) && (Array.isArray(extras) || !extras); var dst = array && [] || {}; if (array) { - target = target || []; - // Add items from target into dst - dst = dst.concat(target); - // Add non-existent items from source into dst - src.forEach(function(e) { + // extras or base is an array + extras = extras || []; + // Add items from base into dst + dst = dst.concat(base); + // Add non-existent items from extras into dst + extras.forEach(function(e) { if (dst.indexOf(e) === -1) { dst.push(e); } }); } else { - if (target != null && typeof target === 'object') { - // Add properties from target to dst - Object.keys(target).forEach(function(key) { - dst[key] = target[key]; + if (base != null && typeof base === 'object') { + // Add properties from base to dst + Object.keys(base).forEach(function(key) { + if (base[key] && typeof base[key] === 'object') { + // call deepMerge on nested object to operate a deep clone + dst[key] = deepMerge(base[key]); + } else { + dst[key] = base[key]; + } }); } - if (src != null && typeof src === 'object') { - // Source is an object - Object.keys(src).forEach(function(key) { - var srcValue = src[key]; - if (srcValue == null || typeof srcValue !== 'object') { - // The source item value is null, undefined or not an object - dst[key] = srcValue; + if (extras != null && typeof extras === 'object') { + // extras is an object {} + Object.keys(extras).forEach(function(key) { + var extra = extras[key]; + if (extra == null || typeof extra !== 'object') { + // extra item value is null, undefined or not an object + dst[key] = extra; } else { - // The source item value is an object - if (target == null || typeof target !== 'object' || - target[key] == null) { - // If target is not an object or target item value - dst[key] = srcValue; + // The extra item value is an object + if (base == null || typeof base !== 'object' || + base[key] == null) { + // base is not an object or base item value is undefined or null + dst[key] = extra; } else { - dst[key] = mergeSettings(target[key], src[key]); + // call deepMerge on nested object + dst[key] = deepMerge(base[key], extra); } } }); @@ -422,6 +436,53 @@ function mergeSettings(target, src) { return dst; } +/** + * Properties deep merge + * Similar as deepMerge but also works on single properties of any type + * + * @param {Object} base The base property + * @param {Object} extras The property to merge with base + * @returns {Object} The merged property + */ +function deepMergeProperty(base, extras) { + let mergedObject = deepMerge({key: base}, {key: extras}); + let mergedProperty = mergedObject.key; + return mergedProperty; +} + +/** + * Adds a property __rank to array elements of type object {} + * If an inner element already has the __rank property it is not altered + * NOTE: the function mutates the provided array + * + * @param array The original array + * @param rank The rank to apply to array elements + * @return rankedArray The original array with newly ranked elements + */ +function rankArrayElements(array, rank) { + if (!Array.isArray(array) || !Number.isFinite(rank)) + return array; + + array.forEach(function(el) { + // only apply ranking on objects {} in array + if (!el || typeof el != 'object' || Array.isArray(el)) + return; + + // property rank is already defined for array element + if (el.__rank) + return; + + // define rank property as non-enumerable and read-only + Object.defineProperty(el, '__rank', { + writable: false, + enumerable: false, + configurable: false, + value: rank, + }); + }); + return array; +} + /** * Define an non-enumerable __cachedRelations property * @param {Object} obj The obj to receive the __cachedRelations diff --git a/test/loopback-dl.test.js b/test/loopback-dl.test.js index f51dd2a6..f5bc0ad4 100644 --- a/test/loopback-dl.test.js +++ b/test/loopback-dl.test.js @@ -1802,140 +1802,6 @@ describe('ModelBuilder processing json files', function() { {customerId: {type: String, id: true}}, {}, User); assert(Customer.prototype instanceof User); }); - - it('allows model extension', function(done) { - var modelBuilder = new ModelBuilder(); - - var User = modelBuilder.define('User', { - name: String, - bio: ModelBuilder.Text, - approved: Boolean, - joinedAt: Date, - age: Number, - }); - - var Customer = User.extend('Customer', {customerId: {type: String, id: true}}); - - var customer = new Customer({name: 'Joe', age: 20, customerId: 'c01'}); - - customer.should.be.type('object').and.have.property('name', 'Joe'); - customer.should.have.property('name', 'Joe'); - customer.should.have.property('age', 20); - customer.should.have.property('customerId', 'c01'); - customer.should.have.property('bio', undefined); - - // The properties are defined at prototype level - assert.equal(Object.keys(customer).filter(function(k) { - // Remove internal properties - return k.indexOf('__') === -1; - }).length, 0); - var count = 0; - for (var p in customer) { - if (p.indexOf('__') === 0) { - continue; - } - if (typeof customer[p] !== 'function') { - count++; - } - } - assert.equal(count, 7); // Please note there is an injected id from User prototype - assert.equal(Object.keys(customer.toObject()).filter(function(k) { - // Remove internal properties - return k.indexOf('__') === -1; - }).length, 6); - - done(null, customer); - }); - - it('allows model extension with merged settings', function(done) { - var modelBuilder = new ModelBuilder(); - - var User = modelBuilder.define('User', { - name: String, - }, { - defaultPermission: 'ALLOW', - acls: [ - { - principalType: 'ROLE', - principalId: '$everyone', - permission: 'ALLOW', - }, - ], - relations: { - posts: { - type: 'hasMany', - model: 'Post', - }, - }, - }); - - var Customer = User.extend('Customer', - {customerId: {type: String, id: true}}, { - defaultPermission: 'DENY', - acls: [ - { - principalType: 'ROLE', - principalId: '$unauthenticated', - permission: 'DENY', - }, - ], - relations: { - orders: { - type: 'hasMany', - model: 'Order', - }, - }, - } - ); - - assert.deepEqual(User.settings, { - defaultPermission: 'ALLOW', - acls: [ - { - principalType: 'ROLE', - principalId: '$everyone', - permission: 'ALLOW', - }, - ], - relations: { - posts: { - type: 'hasMany', - model: 'Post', - }, - }, - strict: false, - }); - - assert.deepEqual(Customer.settings, { - defaultPermission: 'DENY', - acls: [ - { - principalType: 'ROLE', - principalId: '$everyone', - permission: 'ALLOW', - }, - { - principalType: 'ROLE', - principalId: '$unauthenticated', - permission: 'DENY', - }, - ], - relations: { - posts: { - type: 'hasMany', - model: 'Post', - }, - orders: { - type: 'hasMany', - model: 'Order', - }, - }, - strict: false, - base: User, - }); - - done(); - }); }); describe('DataSource constructor', function() { diff --git a/test/model-definition.test.js b/test/model-definition.test.js index 6713066b..bf7a0e97 100644 --- a/test/model-definition.test.js +++ b/test/model-definition.test.js @@ -257,43 +257,6 @@ describe('ModelDefinition class', function() { done(); }); - it('should inherit prototype using option.base', function() { - var modelBuilder = memory.modelBuilder; - var parent = memory.createModel('parent', {}, { - relations: { - children: { - type: 'hasMany', - model: 'anotherChild', - }, - }, - }); - var baseChild = modelBuilder.define('baseChild'); - baseChild.attachTo(memory); - // the name of this must begin with a letter < b - // for this test to fail - var anotherChild = baseChild.extend('anotherChild'); - - assert(anotherChild.prototype instanceof baseChild); - }); - - it('should ignore inherited options.base', function() { - var modelBuilder = memory.modelBuilder; - var base = modelBuilder.define('base'); - var child = base.extend('child', {}, {base: 'base'}); - var grandChild = child.extend('grand-child'); - assert.equal('child', grandChild.base.modelName); - assert(grandChild.prototype instanceof child); - }); - - it('should ignore inherited options.super', function() { - var modelBuilder = memory.modelBuilder; - var base = modelBuilder.define('base'); - var child = base.extend('child', {}, {super: 'base'}); - var grandChild = child.extend('grand-child'); - assert.equal('child', grandChild.base.modelName); - assert(grandChild.prototype instanceof child); - }); - it('should serialize protected properties into JSON', function() { var modelBuilder = memory.modelBuilder; var ProtectedModel = memory.createModel('protected', {}, { diff --git a/test/model-inheritance.test.js b/test/model-inheritance.test.js new file mode 100644 index 00000000..2606e573 --- /dev/null +++ b/test/model-inheritance.test.js @@ -0,0 +1,619 @@ +// Copyright IBM Corp. 2013,2016. All Rights Reserved. +// Node module: loopback-datasource-juggler +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +// This test is written in mocha+should.js + +'use strict'; + +const should = require('./init.js'); +const assert = require('assert'); + +const jdb = require('../'); +const ModelBuilder = jdb.ModelBuilder; +const DataSource = jdb.DataSource; +const Memory = require('../lib/connectors/memory'); + +const ModelDefinition = require('../lib/model-definition'); + +describe('Model class inheritance', function() { + let memory; + beforeEach(function() { + memory = new DataSource({connector: Memory}); + }); + + describe('ModelBaseClass.getMergePolicy()', function() { + const legacyMergePolicy = { + description: {replace: true}, + properties: {patch: true}, + hidden: {replace: false}, + protected: {replace: false}, + relations: {patch: true}, + acls: {rank: true}, + }; + + const recommendedMergePolicy = { + description: {replace: true}, + options: {patch: true}, + hidden: {replace: false}, + protected: {replace: false}, + indexes: {patch: true}, + methods: {patch: true}, + mixins: {patch: true}, + relations: {patch: true}, + scope: {replace: true}, + scopes: {patch: true}, + acls: {rank: true}, + __delete: null, + __default: {replace: true}, + }; + + let modelBuilder, base; + + beforeEach(function() { + modelBuilder = memory.modelBuilder; + base = modelBuilder.define('base'); + }); + + it('returns legacy merge policy by default', function() { + const mergePolicy = base.getMergePolicy(); + should.deepEqual(mergePolicy, legacyMergePolicy); + }); + + it('returns recommended merge policy when called with option ' + + '`{configureModelMerge: true}`', function() { + const mergePolicy = base.getMergePolicy({configureModelMerge: true}); + should.deepEqual(mergePolicy, recommendedMergePolicy); + }); + + it('handles custom merge policy defined via model.settings', function() { + let mergePolicy; + const newMergePolicy = { + relations: {patch: true}, + }; + + // saving original getMergePolicy method + let originalGetMergePolicy = base.getMergePolicy; + + // the injected getMergePolicy method captures the provided configureModelMerge option + base.getMergePolicy = function(options) { + mergePolicy = options && options.configureModelMerge; + return originalGetMergePolicy(options); + }; + + // calling extend() on base model calls base.getMergePolicy() internally + // child model settings are passed as 3rd parameter + const child = base.extend('child', {}, {configureModelMerge: newMergePolicy}); + + should.deepEqual(mergePolicy, newMergePolicy); + + // restoring original getMergePolicy method + base.getMergePolicy = originalGetMergePolicy; + }); + + it('can be extended by user', function() { + const alteredMergePolicy = Object.assign({}, recommendedMergePolicy, { + __delete: false, + }); + // extending the builtin getMergePolicy function + base.getMergePolicy = function(options) { + const origin = base.base.getMergePolicy(options); + return Object.assign({}, origin, { + __delete: false, + }); + }; + const mergePolicy = base.getMergePolicy({configureModelMerge: true}); + should.deepEqual(mergePolicy, alteredMergePolicy); + }); + + it('is inherited by child model', function() { + const child = base.extend('child', {}, {configureModelMerge: true}); + // get mergePolicy from child + const mergePolicy = child.getMergePolicy({configureModelMerge: true}); + should.deepEqual(mergePolicy, recommendedMergePolicy); + }); + }); + + describe('Merge policy WITHOUT flag `configureModelMerge`', function() { + it('inherits prototype using option.base', function() { + const modelBuilder = memory.modelBuilder; + const parent = memory.createModel('parent', {}, { + relations: { + children: { + type: 'hasMany', + model: 'anotherChild', + }, + }, + }); + const baseChild = modelBuilder.define('baseChild'); + baseChild.attachTo(memory); + // the name of this must begin with a letter < b + // for this test to fail + const anotherChild = baseChild.extend('anotherChild'); + + assert(anotherChild.prototype instanceof baseChild); + }); + + it('ignores inherited options.base', function() { + const modelBuilder = memory.modelBuilder; + const base = modelBuilder.define('base'); + const child = base.extend('child', {}, {base: 'base'}); + const grandChild = child.extend('grand-child'); + assert.equal('child', grandChild.base.modelName); + assert(grandChild.prototype instanceof child); + }); + + it('ignores inherited options.super', function() { + const modelBuilder = memory.modelBuilder; + const base = modelBuilder.define('base'); + const child = base.extend('child', {}, {super: 'base'}); + const grandChild = child.extend('grand-child'); + assert.equal('child', grandChild.base.modelName); + assert(grandChild.prototype instanceof child); + }); + + it('allows model extension', function(done) { + var modelBuilder = new ModelBuilder(); + + var User = modelBuilder.define('User', { + name: String, + bio: ModelBuilder.Text, + approved: Boolean, + joinedAt: Date, + age: Number, + }); + + var Customer = User.extend('Customer', {customerId: {type: String, id: true}}); + + var customer = new Customer({name: 'Joe', age: 20, customerId: 'c01'}); + + customer.should.be.type('object').and.have.property('name', 'Joe'); + customer.should.have.property('name', 'Joe'); + customer.should.have.property('age', 20); + customer.should.have.property('customerId', 'c01'); + customer.should.have.property('bio', undefined); + + // The properties are defined at prototype level + assert.equal(Object.keys(customer).filter(function(k) { + // Remove internal properties + return k.indexOf('__') === -1; + }).length, 0); + var count = 0; + for (var p in customer) { + if (p.indexOf('__') === 0) { + continue; + } + if (typeof customer[p] !== 'function') { + count++; + } + } + assert.equal(count, 7); // Please note there is an injected id from User prototype + assert.equal(Object.keys(customer.toObject()).filter(function(k) { + // Remove internal properties + return k.indexOf('__') === -1; + }).length, 6); + + done(null, customer); + }); + + it('allows model extension with merged settings', function(done) { + var modelBuilder = new ModelBuilder(); + + var User = modelBuilder.define('User', { + name: String, + }, { + defaultPermission: 'ALLOW', + acls: [ + { + principalType: 'ROLE', + principalId: '$everyone', + permission: 'ALLOW', + }, + ], + relations: { + posts: { + type: 'hasMany', + model: 'Post', + }, + }, + }); + + var Customer = User.extend('Customer', + {customerId: {type: String, id: true}}, { + defaultPermission: 'DENY', + acls: [ + { + principalType: 'ROLE', + principalId: '$unauthenticated', + permission: 'DENY', + }, + ], + relations: { + orders: { + type: 'hasMany', + model: 'Order', + }, + }, + } + ); + + assert.deepEqual(User.settings, { + defaultPermission: 'ALLOW', + acls: [ + { + principalType: 'ROLE', + principalId: '$everyone', + permission: 'ALLOW', + }, + ], + relations: { + posts: { + type: 'hasMany', + model: 'Post', + }, + }, + strict: false, + }); + + assert.deepEqual(Customer.settings, { + defaultPermission: 'DENY', + acls: [ + { + principalType: 'ROLE', + principalId: '$everyone', + permission: 'ALLOW', + }, + { + principalType: 'ROLE', + principalId: '$unauthenticated', + permission: 'DENY', + }, + ], + relations: { + posts: { + type: 'hasMany', + model: 'Post', + }, + orders: { + type: 'hasMany', + model: 'Order', + }, + }, + strict: false, + base: User, + }); + + done(); + }); + + it('defines rank of ACLs according to model\'s inheritance rank', function() { + // a simple test is enough as we already fully tested option `{rank: true}` + // in tests with flag `configureModelMerge` + const modelBuilder = memory.modelBuilder; + const base = modelBuilder.define('base', {}, {acls: [ + { + principalType: 'ROLE', + principalId: '$everyone', + property: 'oneMethod', + permission: 'ALLOW', + }, + ]}); + const childRank1 = modelBuilder.define('childRank1', {}, { + base: base, + acls: [ + { + principalType: 'ROLE', + principalId: '$everyone', + property: 'oneMethod', + permission: 'DENY', + }, + ], + }); + + const expectedSettings = { + acls: [ + { + principalType: 'ROLE', + principalId: '$everyone', + property: 'oneMethod', + permission: 'ALLOW', + __rank: 1, + }, + { + principalType: 'ROLE', + principalId: '$everyone', + property: 'oneMethod', + permission: 'DENY', + __rank: 2, + }, + ], + }; + should.deepEqual(childRank1.settings.acls, expectedSettings.acls); + }); + + it('replaces baseClass relations with matching subClass relations', function() { + // merge policy of settings.relations is {patch: true} + const modelBuilder = memory.modelBuilder; + const base = modelBuilder.define('base', {}, { + relations: { + user: { + type: 'belongsTo', + model: 'User', + foreignKey: 'userId', + }, + }, + }); + const child = base.extend('child', {}, { + relations: { + user: { + type: 'belongsTo', + idName: 'id', + polymorphic: { + idType: 'string', + foreignKey: 'userId', + discriminator: 'principalType', + }, + }, + }, + }); + + const expectedSettings = { + relations: { + user: { + type: 'belongsTo', + idName: 'id', + polymorphic: { + idType: 'string', + foreignKey: 'userId', + discriminator: 'principalType', + }, + }, + }, + }; + + should.deepEqual(child.settings.relations, expectedSettings.relations); + }); + }); + + describe('Merge policy WITH flag `configureModelMerge: true`', function() { + it('`{__delete: null}` allows deleting base model settings by assigning ' + + 'null value at sub model level', function() { + const modelBuilder = memory.modelBuilder; + const base = modelBuilder.define('base', {}, { + anyParam: {oneKey: 'this should be removed'}, + }); + const child = base.extend('child', {}, { + anyParam: null, + configureModelMerge: true, + }); + + const expectedSettings = {}; + + should.deepEqual(child.settings.description, expectedSettings.description); + }); + + it('`{rank: true}` defines rank of array elements ' + + 'according to model\'s inheritance rank', function() { + const modelBuilder = memory.modelBuilder; + const base = modelBuilder.define('base', {}, {acls: [ + { + principalType: 'ROLE', + principalId: '$everyone', + property: 'oneMethod', + permission: 'ALLOW', + }, + ]}); + const childRank1 = modelBuilder.define('childRank1', {}, { + base: base, + acls: [ + { + principalType: 'ROLE', + principalId: '$owner', + property: 'anotherMethod', + permission: 'ALLOW', + }, + ], + configureModelMerge: true, + }); + const childRank2 = childRank1.extend('childRank2', {}, {}); + const childRank3 = childRank2.extend('childRank3', {}, { + acls: [ + { + principalType: 'ROLE', + principalId: '$everyone', + property: 'oneMethod', + permission: 'DENY', + }, + ], + configureModelMerge: true, + }); + + const expectedSettings = { + acls: [ + { + principalType: 'ROLE', + principalId: '$everyone', + property: 'oneMethod', + permission: 'ALLOW', + __rank: 1, + }, + { + principalType: 'ROLE', + principalId: '$owner', + property: 'anotherMethod', + permission: 'ALLOW', + __rank: 2, + }, + { + principalType: 'ROLE', + principalId: '$everyone', + property: 'oneMethod', + permission: 'DENY', + __rank: 4, + }, + ], + }; + should.deepEqual(childRank3.settings.acls, expectedSettings.acls); + }); + + it('`{replace: true}` replaces base model array with sub model matching ' + + 'array', function() { + // merge policy of settings.description is {replace: true} + const modelBuilder = memory.modelBuilder; + const base = modelBuilder.define('base', {}, { + description: ['base', 'model', 'description'], + }); + const child = base.extend('child', {}, { + description: ['this', 'is', 'child', 'model', 'description'], + configureModelMerge: true, + }); + + const expectedSettings = { + description: ['this', 'is', 'child', 'model', 'description'], + }; + + should.deepEqual(child.settings.description, expectedSettings.description); + }); + + it('`{replace:true}` is applied on array parameters not defined in merge policy', function() { + const modelBuilder = memory.modelBuilder; + const base = modelBuilder.define('base', {}, { + unknownArrayParam: ['this', 'should', 'be', 'replaced'], + }); + const child = base.extend('child', {}, { + unknownArrayParam: ['this', 'should', 'remain', 'after', 'merge'], + configureModelMerge: true, + }); + + const expectedSettings = { + unknownArrayParam: ['this', 'should', 'remain', 'after', 'merge'], + }; + + should.deepEqual(child.settings.description, expectedSettings.description); + }); + + it('`{replace:true}` is applied on object {} parameters not defined in mergePolicy', function() { + const modelBuilder = memory.modelBuilder; + const base = modelBuilder.define('base', {}, { + unknownObjectParam: {oneKey: 'this should be replaced'}, + }); + const child = base.extend('child', {}, { + unknownObjectParam: {anotherKey: 'this should remain after merge'}, + configureModelMerge: true, + }); + + const expectedSettings = { + unknownObjectParam: {anotherKey: 'this should remain after merge'}, + }; + + should.deepEqual(child.settings.description, expectedSettings.description); + }); + + it('`{replace: false}` adds distinct members of matching arrays from ' + + 'base model and sub model', function() { + // merge policy of settings.hidden is {replace: false} + const modelBuilder = memory.modelBuilder; + const base = modelBuilder.define('base', {}, { + hidden: ['firstProperty', 'secondProperty'], + }); + const child = base.extend('child', {}, { + hidden: ['secondProperty', 'thirdProperty'], + configureModelMerge: true, + }); + + const expectedSettings = { + hidden: ['firstProperty', 'secondProperty', 'thirdProperty'], + }; + + should.deepEqual(child.settings.hidden, expectedSettings.hidden); + }); + + it('`{patch: true}` adds distinct inner properties of matching objects ' + + 'from base model and sub model', function() { + // merge policy of settings.relations is {patch: true} + const modelBuilder = memory.modelBuilder; + const base = modelBuilder.define('base', {}, { + relations: { + someOtherRelation: { + type: 'hasMany', + model: 'someOtherModel', + foreignKey: 'otherModelId', + }, + }, + }); + const child = base.extend('child', {}, { + relations: { + someRelation: { + type: 'belongsTo', + model: 'someModel', + foreignKey: 'modelId', + }, + }, + configureModelMerge: true, + }); + + const expectedSettings = { + relations: { + someRelation: { + type: 'belongsTo', + model: 'someModel', + foreignKey: 'modelId', + }, + someOtherRelation: { + type: 'hasMany', + model: 'someOtherModel', + foreignKey: 'otherModelId', + }, + }, + }; + + should.deepEqual(child.settings.relations, expectedSettings.relations); + }); + + it('`{patch: true}` replaces baseClass inner properties with matching ' + + 'subClass inner properties', function() { + // merge policy of settings.relations is {patch: true} + const modelBuilder = memory.modelBuilder; + const base = modelBuilder.define('base', {}, { + relations: { + user: { + type: 'belongsTo', + model: 'User', + foreignKey: 'userId', + }, + }, + }); + const child = base.extend('child', {}, { + relations: { + user: { + type: 'belongsTo', + idName: 'id', + polymorphic: { + idType: 'string', + foreignKey: 'userId', + discriminator: 'principalType', + }, + }, + }, + configureModelMerge: true, + }); + + const expectedSettings = { + relations: { + user: { + type: 'belongsTo', + idName: 'id', + polymorphic: { + idType: 'string', + foreignKey: 'userId', + discriminator: 'principalType', + }, + }, + }, + }; + + should.deepEqual(child.settings.relations, expectedSettings.relations); + }); + }); +}); diff --git a/test/util.test.js b/test/util.test.js index 3374775a..77ea8d1c 100644 --- a/test/util.test.js +++ b/test/util.test.js @@ -8,7 +8,8 @@ var should = require('./init.js'); var utils = require('../lib/utils'); var fieldsToArray = utils.fieldsToArray; var removeUndefined = utils.removeUndefined; -var mergeSettings = utils.mergeSettings; +var deepMerge = utils.deepMerge; +var rankArrayElements = utils.rankArrayElements; var mergeIncludes = utils.mergeIncludes; var sortObjectsByIds = utils.sortObjectsByIds; var uniq = utils.uniq; @@ -138,9 +139,9 @@ describe('util.parseSettings', function() { }); }); -describe('mergeSettings', function() { - it('should merge settings correctly', function() { - var src = {base: 'User', +describe('util.deepMerge', function() { + it('should deep merge objects', function() { + var extras = {base: 'User', relations: {accessTokens: {model: 'accessToken', type: 'hasMany', foreignKey: 'userId'}, account: {model: 'account', type: 'belongsTo'}}, @@ -159,7 +160,7 @@ describe('mergeSettings', function() { principalType: 'ROLE', principalId: '$owner'}, ]}; - var tgt = {strict: false, + var base = {strict: false, acls: [ {principalType: 'ROLE', principalId: '$everyone', @@ -173,7 +174,7 @@ describe('mergeSettings', function() { maxTTL: 31556926, ttl: 1209600}; - var dst = mergeSettings(tgt, src); + var merged = deepMerge(base, extras); var expected = {strict: false, acls: [ @@ -206,11 +207,41 @@ describe('mergeSettings', function() { foreignKey: 'userId'}, account: {model: 'account', type: 'belongsTo'}}}; - should.deepEqual(dst.acls, expected.acls, 'Merged settings should match the expectation'); + should.deepEqual(merged, expected, 'Merged objects should match the expectation'); }); }); -describe('sortObjectsByIds', function() { +describe('util.rankArrayElements', function() { + it('should add property \'__rank\' to array elements of type object {}', function() { + var acls = [ + {accessType: '*', + permission: 'DENY', + principalType: 'ROLE', + principalId: '$everyone'}, + ]; + + var rankedAcls = rankArrayElements(acls, 2); + + should.equal(rankedAcls[0].__rank, 2); + }); + + it('should not replace existing \'__rank\' property of array elements', function() { + var acls = [ + {accessType: '*', + permission: 'DENY', + principalType: 'ROLE', + principalId: '$everyone', + __rank: 1, + }, + ]; + + var rankedAcls = rankArrayElements(acls, 2); + + should.equal(rankedAcls[0].__rank, 1); + }); +}); + +describe('util.sortObjectsByIds', function() { var items = [ {id: 1, name: 'a'}, {id: 2, name: 'b'},