Skip to content

Commit

Permalink
Add __parent reference to embedded models
Browse files Browse the repository at this point in the history
Add a new hidden property `__parent` that's automatically set on all
instances of embedded models.

For backwards compatibility, this feature is not enabled by default.
You can turn it on by adding the following line to `server/server.js`
file:

    app.registry.modelBuilder.settings.parentRef = true;
  • Loading branch information
mitsos1os authored and bajtos committed Apr 17, 2020
1 parent 9bcf424 commit b4c093c
Show file tree
Hide file tree
Showing 8 changed files with 285 additions and 2 deletions.
6 changes: 6 additions & 0 deletions lib/list.js
Expand Up @@ -8,6 +8,9 @@
const g = require('strong-globalize')();
const util = require('util');
const Any = require('./types').Types.Any;
const {
applyParentProperty,
} = require('./utils');

module.exports = List;

Expand Down Expand Up @@ -61,6 +64,7 @@ function List(items, itemType, parent) {
});

if (parent) {
// List constructor now called with actual model instance
Object.defineProperty(arr, 'parent', {
writable: true,
enumerable: false,
Expand All @@ -74,6 +78,7 @@ function List(items, itemType, parent) {
} else {
arr[i] = item;
}
if (parent && arr[i] && typeof arr[i] === 'object') applyParentProperty(arr[i], parent);
});

return arr;
Expand All @@ -100,6 +105,7 @@ List.prototype.toItem = function(item) {

List.prototype.push = function(obj) {
const item = this.itemType && (obj instanceof this.itemType) ? obj : this.toItem(obj);
if (item && typeof item === 'object' && this.parent) applyParentProperty(item, this.parent);
_push.call(this, item);
return item;
};
Expand Down
9 changes: 7 additions & 2 deletions lib/model-builder.js
Expand Up @@ -24,6 +24,7 @@ const {
deepMergeProperty,
rankArrayElements,
isClass,
applyParentProperty,
} = require('./utils');

// Set up types
Expand Down Expand Up @@ -596,14 +597,18 @@ ModelBuilder.prototype.define = function defineClass(className, properties, sett
} else {
if (DataType === List) {
this.__data[propertyName] = isClass(DataType) ?
new DataType(value, properties[propertyName].type, this.__data) :
DataType(value, properties[propertyName].type, this.__data);
new DataType(value, properties[propertyName].type, this) :
DataType(value, properties[propertyName].type, this);
} else {
// Assume the type constructor handles Constructor() call
// If not, we should call new DataType(value).valueOf();
this.__data[propertyName] = (value instanceof DataType) ?
value :
isClass(DataType) ? new DataType(value) : DataType(value);
if (value && this.__data[propertyName] instanceof DefaultModelBaseClass) {
// we are dealing with an embedded model, apply parent
applyParentProperty(this.__data[propertyName], this);
}
}
}
}
Expand Down
1 change: 1 addition & 0 deletions lib/model.js
Expand Up @@ -353,6 +353,7 @@ ModelBaseClass.prototype._initProperties = function(data, options) {
typeof self.__data[p] === 'object' &&
self.__data[p] !== null) {
self.__data[p] = new type(self.__data[p]);
utils.applyParentProperty(self.__data[p], this);
}
} else if (type.name === 'Array' || Array.isArray(type)) {
if (!(self.__data[p] instanceof List) &&
Expand Down
51 changes: 51 additions & 0 deletions lib/utils.js
Expand Up @@ -30,12 +30,25 @@ exports.rankArrayElements = rankArrayElements;
exports.idsHaveDuplicates = idsHaveDuplicates;
exports.isClass = isClass;
exports.escapeRegExp = escapeRegExp;
exports.applyParentProperty = applyParentProperty;

const g = require('strong-globalize')();
const traverse = require('traverse');
const assert = require('assert');
const debug = require('debug')('loopback:juggler:utils');

/**
* The name of the property in modelBuilder settings that will enable the child parent reference functionality
* @type {string}
*/
const BUILDER_PARENT_SETTING = 'parentRef';

/**
* The property name that should be defined on each child instance if parent feature flag enabled
* @type {string}
*/
const PARENT_PROPERTY_NAME = '__parent';

function safeRequire(module) {
try {
return require(module);
Expand Down Expand Up @@ -842,3 +855,41 @@ function idsHaveDuplicates(ids) {
function isClass(fn) {
return fn && fn.toString().startsWith('class ');
}

/**
* Accept an element, and attach the __parent property to it, unless no object given, while also
* making sure to check for already created properties
*
* @param {object} element
* @param {Model} parent
*/
function applyParentProperty(element, parent) {
assert.strictEqual(typeof element, 'object', 'Non object element given to assign parent');
const {constructor: {modelBuilder: {settings: builderSettings} = {}} = {}} = element;
if (!builderSettings || !builderSettings[BUILDER_PARENT_SETTING]) {
// parentRef flag not enabled on ModelBuilder settings
return;
}

if (element.hasOwnProperty(PARENT_PROPERTY_NAME)) {
// property already created on model, just assign
const existingParent = element[PARENT_PROPERTY_NAME];
if (existingParent && existingParent !== parent) {
// parent re-assigned (child model assigned to other model instance)
g.warn('Re-assigning child model instance to another parent than the original!\n' +
'Although supported, this is not a recommended practice: ' +
`${element.constructor.name} -> ${parent.constructor.name}\n` +
'You should create an independent copy of the child model using `new Model(CHILD)` OR ' +
'`new Model(CHILD.toJSON())` and assign to new parent');
}
element[PARENT_PROPERTY_NAME] = parent;
} else {
// first time defining the property on the element
Object.defineProperty(element, PARENT_PROPERTY_NAME, {
value: parent,
writable: true,
enumerable: false,
configurable: false,
});
}
}
21 changes: 21 additions & 0 deletions test/basic-querying.test.js
Expand Up @@ -11,6 +11,7 @@ const async = require('async');
const bdd = require('./helpers/bdd-if');
const should = require('./init.js');
const uid = require('./helpers/uid-generator');
const createTestSetupForParentRef = require('./helpers/setup-parent-ref');

let db, User;

Expand Down Expand Up @@ -958,6 +959,26 @@ describe('basic-querying', function() {
null, // databases representing `undefined` as `null` (e.g. SQL)
]);
});

describe('check __parent relationship in embedded models', () => {
createTestSetupForParentRef(() => User.modelBuilder);
it('should fill the parent in embedded model', async () => {
const user = await User.findOne({where: {name: 'John Lennon'}});
user.should.have.property('address').which.has.property('__parent').which.is
.instanceof(User).and.equals(user);
});
it('should assign the container model as parent in list property', async () => {
const user = await User.findOne({where: {name: 'John Lennon'}});
user.should.have.property('friends').which.has.property('parent').which.is
.instanceof(User).and.equals(user);
});
it('should have the complete chain of parents available in embedded list element', async () => {
const user = await User.findOne({where: {name: 'John Lennon'}});
user.friends.forEach((userFriend) => {
userFriend.should.have.property('__parent').which.equals(user);
});
});
});
});

describe('count', function() {
Expand Down
24 changes: 24 additions & 0 deletions test/helpers/setup-parent-ref.js
@@ -0,0 +1,24 @@
'use strict';

const assert = require('assert');

/**
* Helper function that when called should return the current instance of the modelBuilder
* @param {function: ModelBuilder} getBuilder
*/
const createTestSetupForParentRef = (getBuilder) => {
assert.strictEqual(typeof getBuilder, 'function', 'Missing getter function for model builder');
const settingProperty = 'parentRef';
beforeEach('enabling parentRef for given modelBuilder', () => {
const modelBuilder = getBuilder();
assert(modelBuilder && typeof modelBuilder === 'object', 'Invalid modelBuilder instance');
modelBuilder.settings[settingProperty] = true;
});
afterEach('Disabling parentRef for given modelBuilder', () => {
const modelBuilder = getBuilder();
assert(modelBuilder && typeof modelBuilder === 'object', 'Invalid modelBuilder instance');
modelBuilder.settings[settingProperty] = false;
});
};

module.exports = createTestSetupForParentRef;
56 changes: 56 additions & 0 deletions test/list.test.js
Expand Up @@ -7,6 +7,10 @@

const should = require('./init.js');
const List = require('../lib/list');
const parentRefHelper = require('./helpers/setup-parent-ref');
const {ModelBuilder} = require('../lib/model-builder');

const builder = new ModelBuilder(); // dummy builder instance for tests

/**
* Phone as a class
Expand All @@ -25,6 +29,12 @@ class Phone {
}
}

/**
* Dummy property for testing parent reference
* @type {ModelBuilder}
*/
Phone.modelBuilder = builder;

/**
* Phone as a constructor function
* @param {string} label
Expand All @@ -38,6 +48,12 @@ function PhoneCtor(label, num) {
this.num = num;
}

/**
* Dummy property for testing parent reference
* @type {ModelBuilder}
*/
PhoneCtor.modelBuilder = builder;

describe('Does not break default Array functionality', function() {
it('allows creating an empty length with a specified length', function() {
const list = new List(4);
Expand All @@ -49,6 +65,7 @@ describe('Does not break default Array functionality', function() {
});

describe('list of items typed by a class', function() {
parentRefHelper(() => builder);
it('allows itemType to be a class', function() {
const phones = givenPhones();

Expand Down Expand Up @@ -78,9 +95,29 @@ describe('list of items typed by a class', function() {
list.push(phones[0]);
list[0].should.be.an.instanceOf(Phone);
});

it('should assign the list\'s parent as parent to every child element', () => {
const phones = givenPhones();
const listParent = {name: 'PhoneBook'};
const list = new List(phones, Phone, listParent);
list.forEach((listItem) => {
listItem.should.have.property('__parent').which.equals(listParent);
});
});

it('should assign the list\'s parent as element parent with push', () => {
const phones = givenPhonesAsJSON();
const listParent = {name: 'PhoneBook'};
const list = new List([], Phone, listParent);
list.push(phones[0], phones[1]);
list.forEach((listItem) => {
listItem.should.have.property('__parent').which.equals(listParent);
});
});
});

describe('list of items typed by a ctor', function() {
parentRefHelper(() => builder);
it('allows itemType to be a ctor', function() {
const phones = givenPhonesWithCtor();

Expand Down Expand Up @@ -110,6 +147,25 @@ describe('list of items typed by a ctor', function() {
list.push(phones[0]);
list[0].should.be.an.instanceOf(PhoneCtor);
});

it('should assign the list\'s parent as parent to every child element', () => {
const phones = givenPhones();
const listParent = {name: 'PhoneBook'};
const list = new List(phones, PhoneCtor, listParent);
list.forEach((listItem) => {
listItem.should.have.property('__parent').which.equals(listParent);
});
});

it('should assign the list\'s parent as element parent with push', () => {
const phones = givenPhonesAsJSON();
const listParent = {name: 'PhoneBook'};
const list = new List([], PhoneCtor, listParent);
list.push(phones[0], phones[1]);
list.forEach((listItem) => {
listItem.should.have.property('__parent').which.equals(listParent);
});
});
});

function givenPhones() {
Expand Down

0 comments on commit b4c093c

Please sign in to comment.