Skip to content

Commit 0448184

Browse files
authored
Merge pull request #2959 from kobaska/add-multi-tenancy
Support multi-tenancy in change replication - allow apps to extend Change model with extra properties
2 parents 011dc1f + 3f4b5ec commit 0448184

File tree

4 files changed

+293
-16
lines changed

4 files changed

+293
-16
lines changed

common/models/change.js

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -184,16 +184,32 @@ module.exports = function(Change) {
184184

185185
cb = cb || utils.createPromiseCallback();
186186

187-
change.currentRevision(function(err, rev) {
187+
const model = this.getModelCtor();
188+
const id = this.getModelId();
189+
190+
model.findById(id, function(err, inst) {
188191
if (err) return cb(err);
189192

193+
if (inst) {
194+
inst.fillCustomChangeProperties(change, function() {
195+
const rev = Change.revisionForInst(inst);
196+
prepareAndDoRectify(rev);
197+
});
198+
} else {
199+
prepareAndDoRectify(null);
200+
}
201+
});
202+
203+
return cb.promise;
204+
205+
function prepareAndDoRectify(rev) {
190206
// avoid setting rev and prev to the same value
191207
if (currentRev === rev) {
192208
change.debug('rev and prev are equal (not updating anything)');
193209
return cb(null, change);
194210
}
195211

196-
// FIXME(@bajtos) Allo callers to pass in the checkpoint value
212+
// FIXME(@bajtos) Allow callers to pass in the checkpoint value
197213
// (or even better - a memoized async function to get the cp value)
198214
// That will enable `rectifyAll` to cache the checkpoint value
199215
change.constructor.getCheckpointModel().current(
@@ -202,8 +218,7 @@ module.exports = function(Change) {
202218
doRectify(checkpoint, rev);
203219
}
204220
);
205-
});
206-
return cb.promise;
221+
}
207222

208223
function doRectify(checkpoint, rev) {
209224
if (rev) {
@@ -228,7 +243,7 @@ module.exports = function(Change) {
228243
if (currentRev) {
229244
change.prev = currentRev;
230245
} else if (!change.prev) {
231-
change.debug('ERROR - could not determing prev');
246+
change.debug('ERROR - could not determine prev');
232247
change.prev = Change.UNKNOWN;
233248
}
234249
change.debug('updated prev');

lib/persisted-model.js

Lines changed: 78 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1052,17 +1052,15 @@ module.exports = function(registry) {
10521052
var idName = this.dataSource.idName(this.modelName);
10531053
var Change = this.getChangeModel();
10541054
var model = this;
1055+
const changeFilter = this.createChangeFilter(since, filter);
10551056

10561057
filter = filter || {};
10571058
filter.fields = {};
10581059
filter.where = filter.where || {};
10591060
filter.fields[idName] = true;
10601061

10611062
// TODO(ritch) this whole thing could be optimized a bit more
1062-
Change.find({where: {
1063-
checkpoint: {gte: since},
1064-
modelName: this.modelName,
1065-
}}, function(err, changes) {
1063+
Change.find(changeFilter, function(err, changes) {
10661064
if (err) return callback(err);
10671065
if (!Array.isArray(changes) || changes.length === 0) return callback(null, []);
10681066
var ids = changes.map(function(change) {
@@ -1759,11 +1757,12 @@ module.exports = function(registry) {
17591757
assert(BaseChangeModel,
17601758
'Change model must be defined before enabling change replication');
17611759

1760+
const additionalChangeModelProperties =
1761+
this.settings.additionalChangeModelProperties || {};
1762+
17621763
this.Change = BaseChangeModel.extend(this.modelName + '-change',
1763-
{},
1764-
{
1765-
trackModel: this,
1766-
}
1764+
additionalChangeModelProperties,
1765+
{trackModel: this}
17671766
);
17681767

17691768
if (this.dataSource) {
@@ -1928,6 +1927,77 @@ module.exports = function(registry) {
19281927
}
19291928
};
19301929

1930+
/**
1931+
* Get the filter for searching related changes.
1932+
*
1933+
* Models should override this function to copy properties
1934+
* from the model instance filter into the change search filter.
1935+
*
1936+
* ```js
1937+
* module.exports = (TargetModel, config) => {
1938+
* TargetModel.createChangeFilter = function(since, modelFilter) {
1939+
* const filter = this.base.createChangeFilter.apply(this, arguments);
1940+
* if (modelFilter && modelFilter.where && modelFilter.where.tenantId) {
1941+
* filter.where.tenantId = modelFilter.where.tenantId;
1942+
* }
1943+
* return filter;
1944+
* };
1945+
* };
1946+
* ```
1947+
*
1948+
* @param {Number} since Return only changes since this checkpoint.
1949+
* @param {Object} modelFilter Filter describing which model instances to
1950+
* include in the list of changes.
1951+
* @returns {Object} The filter object to pass to `Change.find()`. Default:
1952+
* ```
1953+
* {where: {checkpoint: {gte: since}, modelName: this.modelName}}
1954+
* ```
1955+
*/
1956+
PersistedModel.createChangeFilter = function(since, modelFilter) {
1957+
return {
1958+
where: {
1959+
checkpoint: {gte: since},
1960+
modelName: this.modelName,
1961+
},
1962+
};
1963+
};
1964+
1965+
/**
1966+
* Add custom data to the Change instance.
1967+
*
1968+
* Models should override this function to duplicate model instance properties
1969+
* to the Change instance properties, typically to allow the changes() method
1970+
* to filter the changes using these duplicated properties directly while
1971+
* querying the Change model.
1972+
*
1973+
* ```js
1974+
* module.exports = (TargetModel, config) => {
1975+
* TargetModel.prototype.fillCustomChangeProperties = function(change, cb) {
1976+
* var inst = this;
1977+
* const base = this.constructor.base;
1978+
* base.prototype.fillCustomChangeProperties.call(this, change, err => {
1979+
* if (err) return cb(err);
1980+
*
1981+
* if (inst && inst.tenantId) {
1982+
* change.tenantId = inst.tenantId;
1983+
* } else {
1984+
* change.tenantId = null;
1985+
* }
1986+
*
1987+
* cb();
1988+
* });
1989+
* };
1990+
* };
1991+
* ```
1992+
*
1993+
* @callback {Function} callback
1994+
* @param {Error} err Error object; see [Error object](http://loopback.io/doc/en/lb3/Error-object.html).
1995+
*/
1996+
PersistedModel.prototype.fillCustomChangeProperties = function(change, cb) {
1997+
// no-op by default
1998+
cb();
1999+
};
2000+
19312001
PersistedModel.setup();
19322002

19332003
return PersistedModel;

test/change.test.js

Lines changed: 67 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,9 @@ var async = require('async');
99
var expect = require('./helpers/expect');
1010
var loopback = require('../');
1111

12-
var Change, TestModel;
13-
1412
describe('Change', function() {
13+
let Change, TestModel;
14+
1515
beforeEach(function() {
1616
var memory = loopback.createDataSource({
1717
connector: loopback.Memory,
@@ -321,7 +321,6 @@ describe('Change', function() {
321321
change.rectify()
322322
.then(function(ch) {
323323
assert.equal(ch.rev, test.revisionForModel);
324-
325324
done();
326325
})
327326
.catch(done);
@@ -609,3 +608,68 @@ describe('Change', function() {
609608
});
610609
});
611610
});
611+
612+
describe('Change with with custom properties', function() {
613+
let Change, TestModel;
614+
615+
beforeEach(function() {
616+
let memory = loopback.createDataSource({
617+
connector: loopback.Memory,
618+
});
619+
620+
TestModel = loopback.PersistedModel.extend('ChangeTestModelWithTenant',
621+
{
622+
id: {id: true, type: 'string', defaultFn: 'guid'},
623+
tenantId: 'string',
624+
},
625+
{
626+
trackChanges: true,
627+
additionalChangeModelProperties: {tenantId: 'string'},
628+
});
629+
this.modelName = TestModel.modelName;
630+
631+
TestModel.prototype.fillCustomChangeProperties = function(change, cb) {
632+
var inst = this;
633+
634+
if (inst && inst.tenantId) {
635+
change.tenantId = inst.tenantId;
636+
} else {
637+
change.tenantId = null;
638+
}
639+
640+
cb();
641+
};
642+
643+
TestModel.attachTo(memory);
644+
TestModel._defineChangeModel();
645+
Change = TestModel.getChangeModel();
646+
});
647+
648+
describe('change.rectify', function() {
649+
const TENANT_ID = '123';
650+
let change;
651+
652+
beforeEach(givenChangeInstance);
653+
654+
it('stores the custom property in the Change instance', function() {
655+
return change.rectify().then(function(ch) {
656+
expect(ch.toObject()).to.have.property('tenantId', TENANT_ID);
657+
});
658+
});
659+
660+
function givenChangeInstance() {
661+
const data = {
662+
foo: 'bar',
663+
tenantId: TENANT_ID,
664+
};
665+
666+
return TestModel.create(data)
667+
.then(function(model) {
668+
const modelName = TestModel.modelName;
669+
return Change.findOrCreateChange(modelName, model.id);
670+
}).then(function(ch) {
671+
change = ch;
672+
});
673+
}
674+
});
675+
});

test/replication.test.js

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1905,3 +1905,131 @@ describe('Replication / Change APIs', function() {
19051905
});
19061906
}
19071907
});
1908+
1909+
describe('Replication / Change APIs with custom change properties', function() {
1910+
this.timeout(10000);
1911+
var dataSource, useSinceFilter, SourceModel, TargetModel, startingCheckpoint;
1912+
var tid = 0; // per-test unique id used e.g. to build unique model names
1913+
1914+
beforeEach(function() {
1915+
tid++;
1916+
useSinceFilter = false;
1917+
var test = this;
1918+
1919+
dataSource = this.dataSource = loopback.createDataSource({
1920+
connector: loopback.Memory,
1921+
});
1922+
SourceModel = this.SourceModel = PersistedModel.extend(
1923+
'SourceModelWithCustomChangeProperties-' + tid,
1924+
{
1925+
id: {id: true, type: String, defaultFn: 'guid'},
1926+
customProperty: {type: 'string'},
1927+
},
1928+
{
1929+
trackChanges: true,
1930+
additionalChangeModelProperties: {customProperty: {type: 'string'}},
1931+
});
1932+
1933+
SourceModel.createChangeFilter = function(since, modelFilter) {
1934+
const filter = this.base.createChangeFilter.apply(this, arguments);
1935+
if (modelFilter && modelFilter.where && modelFilter.where.customProperty)
1936+
filter.where.customProperty = modelFilter.where.customProperty;
1937+
return filter;
1938+
};
1939+
1940+
SourceModel.prototype.fillCustomChangeProperties = function(change, cb) {
1941+
const customProperty = this.customProperty;
1942+
const base = this.constructor.base;
1943+
base.prototype.fillCustomChangeProperties.call(this, change, err => {
1944+
if (err) return cb(err);
1945+
change.customProperty = customProperty;
1946+
cb();
1947+
});
1948+
};
1949+
1950+
SourceModel.attachTo(dataSource);
1951+
1952+
TargetModel = this.TargetModel = PersistedModel.extend(
1953+
'TargetModelWithCustomChangeProperties-' + tid,
1954+
{
1955+
id: {id: true, type: String, defaultFn: 'guid'},
1956+
customProperty: {type: 'string'},
1957+
},
1958+
{
1959+
trackChanges: true,
1960+
additionalChangeModelProperties: {customProperty: {type: 'string'}},
1961+
});
1962+
1963+
var ChangeModelForTarget = TargetModel.Change;
1964+
ChangeModelForTarget.Checkpoint = loopback.Checkpoint.extend('TargetCheckpoint');
1965+
ChangeModelForTarget.Checkpoint.attachTo(dataSource);
1966+
1967+
TargetModel.attachTo(dataSource);
1968+
1969+
startingCheckpoint = -1;
1970+
});
1971+
1972+
describe('Model._defineChangeModel()', function() {
1973+
it('defines change model with custom properties', function() {
1974+
var changeModel = SourceModel.getChangeModel();
1975+
var changeModelProperties = changeModel.definition.properties;
1976+
1977+
expect(changeModelProperties).to.have.property('customProperty');
1978+
});
1979+
});
1980+
1981+
describe('Model.changes(since, filter, callback)', function() {
1982+
beforeEach(givenSomeSourceModelInstances);
1983+
1984+
it('queries changes using customized filter', function(done) {
1985+
var filterUsed = mockChangeFind(this.SourceModel);
1986+
1987+
SourceModel.changes(
1988+
startingCheckpoint,
1989+
{where: {customProperty: '123'}},
1990+
function(err, changes) {
1991+
if (err) return done(err);
1992+
expect(filterUsed[0]).to.eql({
1993+
where: {
1994+
checkpoint: {gte: -1},
1995+
modelName: SourceModel.modelName,
1996+
customProperty: '123',
1997+
},
1998+
});
1999+
done();
2000+
});
2001+
});
2002+
2003+
it('query returns the matching changes', function(done) {
2004+
SourceModel.changes(
2005+
startingCheckpoint,
2006+
{where: {customProperty: '123'}},
2007+
function(err, changes) {
2008+
expect(changes).to.have.length(1);
2009+
expect(changes[0]).to.have.property('customProperty', '123');
2010+
done();
2011+
});
2012+
});
2013+
2014+
function givenSomeSourceModelInstances(done) {
2015+
const data = [
2016+
{name: 'foo', customProperty: '123'},
2017+
{name: 'foo', customPropertyValue: '456'},
2018+
];
2019+
this.SourceModel.create(data, done);
2020+
}
2021+
});
2022+
2023+
function mockChangeFind(Model) {
2024+
var filterUsed = [];
2025+
2026+
Model.getChangeModel().find = function(filter, cb) {
2027+
filterUsed.push(filter);
2028+
if (cb) {
2029+
process.nextTick(cb);
2030+
}
2031+
};
2032+
2033+
return filterUsed;
2034+
}
2035+
});

0 commit comments

Comments
 (0)