-
Notifications
You must be signed in to change notification settings - Fork 1.2k
Support multi-tenancy in change replication - allow apps to extend Change model with extra properties #2959
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Conversation
Can one of the admins verify this patch? |
3 similar comments
Can one of the admins verify this patch? |
Can one of the admins verify this patch? |
Can one of the admins verify this patch? |
@bajtos Can this test be rerun. It is failing on existing code. |
Hi @kobaska, thank you for the pull request. I am rather reluctant to add this feature directly to LoopBack, as it's too specific in my opinion. I would prefer to see it in an independent mixin instead. If the current code in LoopBack does not provide enough extension points allowing to implement such mixin, then I am happy to add those extension points only. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
See the comments below for more details how to implement your tenant-based change replication in a mixin.
common/models/change.js
Outdated
change.debug('rev and prev are equal (not updating anything)'); | ||
return cb(null, change); | ||
} | ||
change.tenant = getTenant(model, inst); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If I understand this part correctly, then it's adding a computed tenant
property to each Change instance. I think it should be possible to accomplish this by using "before save" or "persist" hook.
module.exports = myMixinFunction(TargetModel, config) {
TargetModel.getChangeModel().observe('before save', function(ctx, next) {
var tenant = getTenant(TargetModel, ctx.instance || ctx.data);
if (tenant) {
(ctx.instance || ctx.data).tenant = tenant;
}
});
});
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@bajtos
An issue that has risen with this solution, is that we have to pass the Model instance instead of change instance to the getTenant function. We could do a query for the model instance as we have the model id from the ctx, but this adds another query to each save.
Adding tenant to the change object and calculating the rev, both require the model instance. Our solution gets the model once in rectify function and uses that to set the tenant and revision.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
First of all, I would like to acknowledge that I consider your use case, where you would like to copy certain model properties to each change record, as a valid one that we should support.
The question now is how to implement this feature in a way that's generic enough to be useful for other users of the framework too.
Can we perhaps build on top of my proposal in #2959 (comment), where I am describing a new method PersistedModel.createChangeFilter
used when querying changes, and use a similar approach for the other part where we are creating/updating the change records? Something like PersistedModel.createChangeData
that should supersede current Change.revisionForInstance
method. The downside is that this proposal will require complex changes in several places, as we will have to stop passing a single property revision
and start passing a set of properties instead.
Alternatively, can we perhaps keep your current design, but make it more flexible by calling a method on PersistedModel
instead of calling a hard-coded getTenant
method?
Here is a quick mock-up:
// here in this method
model.findById(id, function(err, inst) {
if (err) return cb(err);
if (inst) {
inst.fillChangeProperties(change);
}
change.currentRevision(inst, function(err, rev) {
// etc.
});
});
// in lib/persisted-model.js
PersistedModel.prototype.fillChangeProperties = function(change) {
// no-op by default
};
Can we come up with a better/more descriptive method name than fillChangeProperties
?
Should this new method by async (accepting a callback arg) to allow the method to possibly fetch additional data?
common/models/change.js
Outdated
}); | ||
|
||
if (instance) { | ||
cb(null, Change.revisionForInst(instance)); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This does not make sense to me. Just replace calls of change.currentRevision(inst)
with change.constructor.revisionForInst(inst)
and then you don't need to change this method.
lib/persisted-model.js
Outdated
* @callback {Function} callback Callback function called with `(err, changes)` arguments. Required. | ||
* @param {Error} err Error object; see [Error object](http://docs.strongloop.com/display/LB/Error+object). | ||
* @param {Array} changes An array of [Change](#change) objects. | ||
*/ | ||
|
||
PersistedModel.changes = function(since, filter, callback) { | ||
PersistedModel.changes = function(since, filter, tenant, callback) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ok, I think this part would be difficult to implement in a mixin with the current loopback code.
Since the "tenant" property is copied from the target PersistedModel to the Change model, one should be able to express the desire to limit synchronization to a certain tenant using existing filter
argument only.
MyModel.changes(since, { where: { tenant: tenantId } }, cb);
Then, inside changes
function, we should be able to detect whether filter
contains tenant
clause and copy it to changeFilter
. The only change needed from LoopBack is to allow mixins to override/customize the way how changeFilter
is built.
// in loopback
PersistedModel.createChangeFilter = function(since, modelFilter) {
return {
where: {
checkpoint: {gte: since},
modelName: this.modelName,
},
};
};
// in your mixin
module.exports = myMixinFunction(TargetModel, config) {
// ...
originalFilterFactory = TargetModel.createChangeFilter;
TargetModel.createChangeFilter = function(since, modelFilter) {
var tenantProperty = // find it out
var filter = originalFilterFactory.call(this, since, modelFilter);
if (modelFilter && modelFilter.where && modelFilter.where[tenantProperty]) {
filter.where.tenant = modelFilter.where[tenantProperty];
}
return filter;
};
};
@kobaska ping, what's the status of this patch? Are you still keen to get it finished? |
be2d8aa
to
5acecc8
Compare
@bajtos, I'm going to be away for a month on holiday. Will do the modifications when I'm back. |
5acecc8
to
3cf9e2b
Compare
@bajtos I have done the changes using mixins instead as you suggested. Can you review this please? |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@kobaska Excellent, welcome back from your holiday :)
The code looks much better now, you are on the right track 👍 Please see my comments below for things to fix and improve.
common/models/change.js
Outdated
@@ -230,7 +248,7 @@ module.exports = function(Change) { | |||
if (currentRev) { | |||
change.prev = currentRev; | |||
} else if (!change.prev) { | |||
change.debug('ERROR - could not determing prev'); | |||
change.debug('ERROR - could not determining prev'); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
could not determine prev
common/models/change.js
Outdated
return cb.promise; | ||
|
||
function prepareAndDoRectify(inst) { | ||
change.currentRevision(inst, function(err, rev) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is there any particular reason why we cannot call directly change.constructor.revisionForInst
here? That way we can keep Change.prototype.currentRevision
unchanged.
common/models/change.json
Outdated
}, | ||
"tenant": { | ||
"type": "string", | ||
"default": null |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Please remove. Any custom Change
properties should be defined by the 3rd-party code (loopback unit-tests, a mixin implementing multi-tenancy).
lib/persisted-model.js
Outdated
* filter as part the change search filter | ||
* @param {Number} since Return only changes since this checkpoint. | ||
* @param {Object} modelFilter filter used for the model | ||
* @returns {{where: {checkpoint: {gte: *}, modelName: *}}} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@returns {Object} The filter object to pass to `Change.find()`, typically `{{where: {checkpoint: {gte: *}, modelName: *}}}`
Could you please include a short example showing how to correctly customize this method (see one of my earlier comments for a code snippet to use).
lib/persisted-model.js
Outdated
* @callback {Function} callback | ||
* @param change | ||
*/ | ||
PersistedModel.prototype.fillChangeTenantValue = function(change, cb) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We need a generic method that will work for other use cases beyond multi-tenancy. I am proposing fillChangeProperties
or addCustomChangeData
test/replication.test.js
Outdated
test.startingCheckpoint = -1; | ||
}); | ||
|
||
describe('Model.changes(since, filter, callback) - tenant variant', function() { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
No need to repeat tenant variant
, please remove.
test/replication.test.js
Outdated
describe('Model.changes(since, filter, callback) - tenant variant', function() { | ||
var tenant = '123'; | ||
|
||
it('Get changes since the given checkpoint for the given tenant', function(done) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
queries changes using improved filter for custom properties
test/replication.test.js
Outdated
setTimeout(function() { | ||
test.SourceModelWithTenant.changes(test.startingCheckpoint, {where: {tenantId: tenant}}, function(err, changes) { | ||
assert.equal(changes.length, 1); | ||
assert.equal(changes[0].tenant, tenant); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I believe this test would pass even without your changes applied. While it's ok to verify that the expected changes are returned, we also need to verify that correct queries were made against the datasource, i.e. that tenantId
filter was applied when making the first find
query on the Change
model.
test/replication.test.js
Outdated
SourceModelWithTenant.changes(FUTURE_CHECKPOINT, {where: {tenantId: tenant}}, function(err, changes) { | ||
if (err) return done(err); | ||
|
||
expect(changes).to.be.empty; //jshint ignore:line |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
expect(changes).to.be.empty();
Just make sure expect
is defined as var expect = require('./helpers/expect')
at the top of this file.
test/replication.test.js
Outdated
}); | ||
}); | ||
|
||
it('excludes changes from older checkpoints', function(done) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think this test is redundant - checkpoint filter is already tested, a custom createChangeFilter
should not affect it as long as it preserves the original filter produced by built-in createChangeFilter
method.
@kobaska ping, what's the status of this patch? |
@bajtos I'll get on it next week. Been busy with other stuff. |
78402e8
to
e9adf16
Compare
Hi @bajtos, I have had another go, have a look and see. Regarding adding the custom property on Change model, I couldn't figure out a way to do that through mixins, as they all run after setup, which is too late. Hence I have added a new property additionalChangeModelProperties in settings to do this. Let me know if there is a better way to do this. |
I think that's a reasonable solution 👍 The code changes looks mostly good to me, I'll make few final improvements myself. First of all, I am going to rebase this patch against |
e9adf16
to
f0d3cf5
Compare
common/models/change.js
Outdated
@@ -184,15 +184,15 @@ module.exports = function(Change) { | |||
|
|||
cb = cb || utils.createPromiseCallback(); | |||
|
|||
var model = this.getModelCtor(); | |||
var id = this.getModelId(); | |||
const model = this.getModelCtor(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
lib/persisted-model.js
Outdated
* var filter = originalFilterFactory.call(this, since, modelFilter); | ||
* if (modelFilter && modelFilter.where && modelFilter.where[customProperty]) { | ||
* filter.where[customProperty] = modelFilter.where[customProperty]; | ||
* TargetModel.createChangeFilter = function(since, modelFilter) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Simplified the example - use hard-coded property name "tenantId", get the base createChangeFilter
from the parent model.
test/change.test.js
Outdated
@@ -610,71 +610,66 @@ describe('Change', function() { | |||
}); | |||
|
|||
describe('Change with with custom properties', function() { | |||
var ChangeWithCustomProperty, TestModelWithModifiedChangeModel; | |||
var customProperty = 'tenantId'; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Similarly here, I find the tests easier to read/understand when the property name is hard-coded.
test/change.test.js
Outdated
|
||
TestModelWithModifiedChangeModel = loopback.PersistedModel | ||
.extend('ChangeTestModel', | ||
TestModel = loopback.PersistedModel.extend('ChangeTestModelWithTenant', |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Since we are already within "Change with with custom properties" block, I feel there is no need to repeat that information in model property names.
It is important to use a unique model name in extend()
arguments though.
test/change.test.js
Outdated
{ | ||
id: {id: true, type: 'string', defaultFn: 'guid'}, | ||
tenantId: 'string', |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This was probably an oversight - the custom property should be defined on both Model and Change, right?
test/change.test.js
Outdated
|
||
beforeEach(function() { | ||
var test = this; | ||
beforeEach(givenChangeInstance); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
additionalChangeModelProperties: {customProperty: {type: 'string'}}, | ||
}); | ||
|
||
SourceModel.createChangeFilter = function(since, modelFilter) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Moved the setup of createChangeFilter
and fillCustomChangeProperties
to the before-each hook, as it's tied to additionalChangeModelProperties
that's configured in this hook too.
test/change.test.js
Outdated
it('creates a new change with the custom property', function() { | ||
return change.rectify() | ||
.then(function(ch) { | ||
assert.equal(ch[customProperty], customPropertyValue); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We prefer the BDD style assertions.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hi @kobaska, thank you for the updated code. I have rebased it on top of the latest master
and cleaned up the code to follow our style guide. I left few comments above to explain some of the changes I made.
Could you please take a look at the result and let me know if it looks good to you? If it does, then I'll squash the commits into a single one and land it.
@slnode test please |
Allow custom properties to be added to Change Model, and make change filter customizable through mixins to allow to add the custom property to the filter used to look up relevant changes during change replication.
19b5a74
to
3f4b5ec
Compare
@slnode test please |
Landed 🎉 Thank you for your contribution! 👏 |
Description
Enable replication to scope changes by tenant. This makes querying for changes faster, as we can filter by tenant where it is provided.
Related issues
Checklist
guide