Merge pull request #1289 from strongloop/fix/preventRelationsMerge

review model settings merge/inheritance policy (util/mergeSettings() ) : relations, acls, ...
This commit is contained in:
Kevin Delisle 2017-05-10 12:56:23 -04:00 committed by GitHub
commit d375d61519
7 changed files with 1028 additions and 233 deletions

View File

@ -17,7 +17,9 @@ var deprecated = require('depd')('loopback-datasource-juggler');
var DefaultModelBaseClass = require('./model.js'); var DefaultModelBaseClass = require('./model.js');
var List = require('./list.js'); var List = require('./list.js');
var ModelDefinition = require('./model-definition.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'); var MixinProvider = require('./mixins');
// Set up types // 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 // Make sure base properties are inherited
// See https://github.com/strongloop/loopback-datasource-juggler/issues/293 // See https://github.com/strongloop/loopback-datasource-juggler/issues/293
if ((parent && !settings.base) || (!parent && settings.base)) { if ((parent && !settings.base) || (!parent && settings.base)) {
@ -197,6 +208,9 @@ ModelBuilder.prototype.define = function defineClass(className, properties, sett
hiddenProperty(ModelClass, 'modelName', className); hiddenProperty(ModelClass, 'modelName', className);
} }
// Iterate sub model inheritance rank over base model rank
ModelClass.__rank = ModelBaseClass.__rank + 1;
util.inherits(ModelClass, ModelBaseClass); util.inherits(ModelClass, ModelBaseClass);
// store class in model pool // 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. * @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} subClassProperties child model properties, added to base model
* @options {Object} settings Model settings, such as relations and acls. * properties.
* * @options {Object} subClassSettings child model settings such as relations and acls,
* merged with base model settings.
*/ */
ModelClass.extend = function(className, subclassProperties, subclassSettings) { ModelClass.extend = function(className, subClassProperties, subClassSettings) {
var properties = ModelClass.definition.properties; var baseClassProperties = ModelClass.definition.properties;
var settings = ModelClass.definition.settings; var baseClassSettings = ModelClass.definition.settings;
subclassProperties = subclassProperties || {}; subClassProperties = subClassProperties || {};
subclassSettings = subclassSettings || {}; subClassSettings = subClassSettings || {};
// Check if subclass redefines the ids // Check if subclass redefines the ids
var idFound = false; var idFound = false;
for (var k in subclassProperties) { for (var k in subClassProperties) {
if (subclassProperties[k] && subclassProperties[k].id) { if (subClassProperties[k] && subClassProperties[k].id) {
idFound = true; idFound = true;
break; break;
} }
} }
// Merging the properties // Merging the properties
var keys = Object.keys(properties); var keys = Object.keys(baseClassProperties);
for (var i = 0, n = keys.length; i < n; i++) { for (var i = 0, n = keys.length; i < n; i++) {
var key = keys[i]; var key = keys[i];
if (idFound && properties[key].id) { if (idFound && baseClassProperties[key].id) {
// don't inherit id properties // don't inherit id properties
continue; continue;
} }
if (subclassProperties[key] === undefined) { if (subClassProperties[key] === undefined) {
var baseProp = properties[key]; var baseProp = baseClassProperties[key];
var basePropCopy = baseProp; var basePropCopy = baseProp;
if (baseProp && typeof baseProp === 'object') { if (baseProp && typeof baseProp === 'object') {
// Deep clone the base prop // Deep clone the base properties
basePropCopy = mergeSettings(null, baseProp); basePropCopy = deepMerge(baseProp);
} }
subclassProperties[key] = basePropCopy; subClassProperties[key] = basePropCopy;
} }
} }
// Merge the settings // Merging the settings
var originalSubclassSettings = subclassSettings; var originalSubclassSettings = subClassSettings;
subclassSettings = mergeSettings(settings, subclassSettings); let mergePolicy = ModelClass.getMergePolicy(subClassSettings);
subClassSettings = mergeSettings(baseClassSettings, subClassSettings, mergePolicy);
// Ensure 'base' is not inherited. Note we don't have to delete 'super' // Ensure 'base' is not inherited. Note we don't have to delete 'super'
// as that is removed from settings by modelBuilder.define and thus // as that is removed from settings by modelBuilder.define and thus
// it is never inherited // it is never inherited
if (!originalSubclassSettings.base) { if (!originalSubclassSettings.base) {
subclassSettings.base = ModelClass; subClassSettings.base = ModelClass;
} }
// Define the subclass // Define the subclass
var subClass = modelBuilder.define(className, subclassProperties, subclassSettings, ModelClass); var subClass = modelBuilder.define(className, subClassProperties, subClassSettings, ModelClass);
// Calling the setup function // Calling the setup function
if (typeof subClass.setup === 'function') { if (typeof subClass.setup === 'function') {
@ -410,6 +426,92 @@ ModelBuilder.prototype.define = function defineClass(className, properties, sett
return subClass; 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 * Register a property for the model class
* @param {String} propertyName Name of the property. * @param {String} propertyName Name of the property.

View File

@ -674,6 +674,159 @@ ModelBaseClass.prototype.setStrict = function(strict) {
this.__strict = 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 // Mixin observer
jutil.mixin(ModelBaseClass, require('./observer')); jutil.mixin(ModelBaseClass, require('./observer'));

View File

@ -9,7 +9,8 @@ exports.fieldsToArray = fieldsToArray;
exports.selectFields = selectFields; exports.selectFields = selectFields;
exports.removeUndefined = removeUndefined; exports.removeUndefined = removeUndefined;
exports.parseSettings = parseSettings; exports.parseSettings = parseSettings;
exports.mergeSettings = exports.deepMerge = mergeSettings; exports.mergeSettings = exports.deepMerge = deepMerge;
exports.deepMergeProperty = deepMergeProperty;
exports.isPlainObject = isPlainObject; exports.isPlainObject = isPlainObject;
exports.defineCachedRelations = defineCachedRelations; exports.defineCachedRelations = defineCachedRelations;
exports.sortObjectsByIds = sortObjectsByIds; exports.sortObjectsByIds = sortObjectsByIds;
@ -24,6 +25,7 @@ exports.idEquals = idEquals;
exports.findIndexOf = findIndexOf; exports.findIndexOf = findIndexOf;
exports.collectTargetIds = collectTargetIds; exports.collectTargetIds = collectTargetIds;
exports.idName = idName; exports.idName = idName;
exports.rankArrayElements = rankArrayElements;
var g = require('strong-globalize')(); var g = require('strong-globalize')();
var traverse = require('traverse'); 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 * NOTE: The function operates as a deep clone when called with a single object
* @param {Object} src The source settings object * argument.
* @returns {Object} The merged settings object *
* @param {Object} base The base object
* @param {Object} extras The object to merge with base
* @returns {Object} The merged object
*/ */
function mergeSettings(target, src) { function deepMerge(base, extras) {
var array = Array.isArray(src); // deepMerge allows undefined extras to allow deep cloning of arrays
var array = Array.isArray(base) && (Array.isArray(extras) || !extras);
var dst = array && [] || {}; var dst = array && [] || {};
if (array) { if (array) {
target = target || []; // extras or base is an array
// Add items from target into dst extras = extras || [];
dst = dst.concat(target); // Add items from base into dst
// Add non-existent items from source into dst dst = dst.concat(base);
src.forEach(function(e) { // Add non-existent items from extras into dst
extras.forEach(function(e) {
if (dst.indexOf(e) === -1) { if (dst.indexOf(e) === -1) {
dst.push(e); dst.push(e);
} }
}); });
} else { } else {
if (target != null && typeof target === 'object') { if (base != null && typeof base === 'object') {
// Add properties from target to dst // Add properties from base to dst
Object.keys(target).forEach(function(key) { Object.keys(base).forEach(function(key) {
dst[key] = target[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') { if (extras != null && typeof extras === 'object') {
// Source is an object // extras is an object {}
Object.keys(src).forEach(function(key) { Object.keys(extras).forEach(function(key) {
var srcValue = src[key]; var extra = extras[key];
if (srcValue == null || typeof srcValue !== 'object') { if (extra == null || typeof extra !== 'object') {
// The source item value is null, undefined or not an object // extra item value is null, undefined or not an object
dst[key] = srcValue; dst[key] = extra;
} else { } else {
// The source item value is an object // The extra item value is an object
if (target == null || typeof target !== 'object' || if (base == null || typeof base !== 'object' ||
target[key] == null) { base[key] == null) {
// If target is not an object or target item value // base is not an object or base item value is undefined or null
dst[key] = srcValue; dst[key] = extra;
} else { } 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; 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 * Define an non-enumerable __cachedRelations property
* @param {Object} obj The obj to receive the __cachedRelations * @param {Object} obj The obj to receive the __cachedRelations

View File

@ -1802,140 +1802,6 @@ describe('ModelBuilder processing json files', function() {
{customerId: {type: String, id: true}}, {}, User); {customerId: {type: String, id: true}}, {}, User);
assert(Customer.prototype instanceof 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() { describe('DataSource constructor', function() {

View File

@ -257,43 +257,6 @@ describe('ModelDefinition class', function() {
done(); 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() { it('should serialize protected properties into JSON', function() {
var modelBuilder = memory.modelBuilder; var modelBuilder = memory.modelBuilder;
var ProtectedModel = memory.createModel('protected', {}, { var ProtectedModel = memory.createModel('protected', {}, {

View File

@ -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);
});
});
});

View File

@ -8,7 +8,8 @@ var should = require('./init.js');
var utils = require('../lib/utils'); var utils = require('../lib/utils');
var fieldsToArray = utils.fieldsToArray; var fieldsToArray = utils.fieldsToArray;
var removeUndefined = utils.removeUndefined; var removeUndefined = utils.removeUndefined;
var mergeSettings = utils.mergeSettings; var deepMerge = utils.deepMerge;
var rankArrayElements = utils.rankArrayElements;
var mergeIncludes = utils.mergeIncludes; var mergeIncludes = utils.mergeIncludes;
var sortObjectsByIds = utils.sortObjectsByIds; var sortObjectsByIds = utils.sortObjectsByIds;
var uniq = utils.uniq; var uniq = utils.uniq;
@ -138,9 +139,9 @@ describe('util.parseSettings', function() {
}); });
}); });
describe('mergeSettings', function() { describe('util.deepMerge', function() {
it('should merge settings correctly', function() { it('should deep merge objects', function() {
var src = {base: 'User', var extras = {base: 'User',
relations: {accessTokens: {model: 'accessToken', type: 'hasMany', relations: {accessTokens: {model: 'accessToken', type: 'hasMany',
foreignKey: 'userId'}, foreignKey: 'userId'},
account: {model: 'account', type: 'belongsTo'}}, account: {model: 'account', type: 'belongsTo'}},
@ -159,7 +160,7 @@ describe('mergeSettings', function() {
principalType: 'ROLE', principalType: 'ROLE',
principalId: '$owner'}, principalId: '$owner'},
]}; ]};
var tgt = {strict: false, var base = {strict: false,
acls: [ acls: [
{principalType: 'ROLE', {principalType: 'ROLE',
principalId: '$everyone', principalId: '$everyone',
@ -173,7 +174,7 @@ describe('mergeSettings', function() {
maxTTL: 31556926, maxTTL: 31556926,
ttl: 1209600}; ttl: 1209600};
var dst = mergeSettings(tgt, src); var merged = deepMerge(base, extras);
var expected = {strict: false, var expected = {strict: false,
acls: [ acls: [
@ -206,11 +207,41 @@ describe('mergeSettings', function() {
foreignKey: 'userId'}, foreignKey: 'userId'},
account: {model: 'account', type: 'belongsTo'}}}; 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 = [ var items = [
{id: 1, name: 'a'}, {id: 1, name: 'a'},
{id: 2, name: 'b'}, {id: 2, name: 'b'},