-
Notifications
You must be signed in to change notification settings - Fork 635
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
Official plugin listing? #335
Comments
+1 for best practices, followed by listing. |
+1 for listing, preceded by best practices. |
Plugins as a sub class is somewhat an anti pattern. Consider having 10 separate plugins, that would been 10 deep prototype inheritance, which would be allot of trips to get the first. Before there's a best practices and a listing, there should be an official plugin design to hook into objection somehow. At current stage any best practice is actually an anti pattern and a false sense of "I'm following convention" feeling. |
I should do this as soon as possible. I've been meaning to, but never got around to it. @fl0w You are absolutely right about inheritance not being the best solution. I think the best practice is actually monkey patching, as crazy as it sounds. Inheritance creates long prototype chains and deep nested constructor calls. v8 can deal with deep prototype chains, but nested constructor calls cost. Adding a plugin mechanism, for example a So for example if your plugin adds a const oldHook = objection.Model.prototype.$beforeUpdate;
objection.Model.prototype.$beforeUpdate = function () {
const res = oldHook.apply(this, arguments);
// Prepare for other plugins that do something async.
return Promise.resolve(res).then(res => {
// Do your thing.
return res;
});
}; thoughts? |
Maybe we could add a objection.plugin = (plugin, options) => {
plugin(objection, options);
return objection;
}; but it would provide a clear way to register plugins. Instead of having this in the beginning of your app.js: const objection = require('objection');
aPlugin(objection.Model);
someOtherPlugin.register(objection);
objection = someThirdPlugin.apply(objection); You would have this. const objection = require('objection');
objection
.plugin(aPlugin)
.plugin(someOtherPlugin)
.plugin(someThirdPlugin) |
Would it be over engineering to have class PluggableModel extends Model {
static plugins = {
$beforeUpdate: [ ... ]
}
$beforeUpdate () {
// loop apply through this.constructor.plugins.$beforeUpdate
}
} This would allow user to define a specific plugin set for a model in cases where performance has to be considered. Edit Maybe not even subclass it, just stick it in |
Do we really need to limit what's publicly pluggable? We have no idea what kind of plugins people want to write. They could add custom methods to Model or QueryBuilder, replace validation with another, add support for rxjs observables, add support for table aliases etc. Overriding hooks is just one of the things. What would |
Not sure, but isn't it quite plausible to break plugins which do stuff to internals which are subject to change (i.e. not public API)? Maybe that's not of concern though. As far as I was mainly just throwing a different solution [to yours] to have something to compare against. |
Yours is definitely more powerful as it'd hook into objection, not QueryBuilder alone. |
It should be clearly stated in the best practices that plugging into methods or classes that are not documented (are not public) is not a good idea. |
That's reasonable and fair! |
Can we think of any situations where a plugin would need dependencies (plugins that rely on others)? HapiJS handles this with Topo. We should think of any special options that might be needed when registering plugins. Also, could we slap an 'enhancement' label on this issue? |
What if the plugins were modules like this: module.exports = {
name: 'my-plugin',
dependencies: ['a-plugin', 'some-other-plugin'],
register(objection, options) {
// Do your stuff.
}
}; And they were registered like this: objection.plugins([
require('my-plugin'),
require('a-plugin'),
require('some-other-plugin')
]); Objection would call the register functions in the correct order based on dependencies. The following would throw because one of the deps is missing. objection.plugins([
require('a-plugin'),
require('my-plugin')
]); Options could be passed like this: objection.plugins([
{plugin: require('my-plugin'), options {foo: 'bar'}},
require('a-plugin'),
require('some-other-plugin')
]); or would this be cleaner: objection.plugins([
[require('my-plugin'), {foo: 'bar'}],
require('a-plugin'),
require('some-other-plugin')
]); ? |
Or maybe objection should not even handle the options. If the plugin takes options it could simply be defined like this module.exports = (options) => ({
name: 'my-plugin',
dependencies: ['a-plugin', 'some-other-plugin'],
register(objection) {
// Do your stuff.
}
}); and used like this: objection.plugins([
require('my-plugin')({foo: 'bar'}),
require('a-plugin'),
require('some-other-plugin')
]); |
I'm a little concerned that we've begun over-engineering! I'm not super into monkey-patching objection's shared What we're talking about are mixins. So for now I don't think we would need anything more sophisticated than plain JS (order-reliant) mixins. I can appreciate that long prototype chains would have a cost, but at least that cost would be transparent to the user. Objection making extensive use of classes while spurning use of Also worth keeping in mind that other libraries, such as Polymer, once had complex plugin/mixin "behaviors" like we're talking about above, and eventually decided to drop them in favor of plain JS class mixins: https://www.polymer-project.org/2.0/docs/upgrade#mixins Example, const MyMixin = (BaseModel) => class extends BaseModel {
$beforeUpdate() {
// Prepare for other plugins that do something async.
return Promise.resolve(super.$beforeUpdate()).then(res => {
// Do your thing.
return res;
});
}
};
class User extends MyMixin(Objection.Model) {} |
I like that! The user should have control over exactly what their base Objection class is going to look like at any point. I think being able to choose between different mixin classes and compose them as desired is better than running each plugin on the BaseModel class and forcing each feature for the whole project. There definitely will be situations where functionality from plugins will be wanted in certain places, and not wanted in other places. One of Objection's most shining attributes is the transparency of what features your Model is going to have, we don't wanna take that away |
@devinivy You definitely have a good point there! Maybe I'm overly concerned about the performance. My fears come from the fact that I just removed a useless model base class Mixins actually seem like the way to go. I just did a full 180° turn here in a matter of minutes. Am I easily convinced or is @devinivy's idea just that great 😄 ? |
I'm not sure this is a matter of micro benchmarks though. A model is constructed for each fetched row. Add 3 or 4 added constructors (because of stacked mixins) and fetch 100 rows and well ... I haven't seen benchmarks, so I'm arguing from ignorance here - but I'm not optimistic. I would assume this does indeed add "worthy" performance hit for simply fetching data. 3 or 4 mixins (or plugins mind you) is a plausible scenario IMO. |
(Because as I understand it, a JS mixin is essentially an implicit |
Yeah, multiple levels of inheritance would cause a performance hit, but would it really be a common case to have 4 or more plugins that extend Prototype chains don't matter. v8 is able to completely remove those. I have once tested a 10000-level long prototype chain and it was just as fast as one level long one. |
I think mixins also bring the benefit of only getting the features you want in any given model. There's no requirement that all models in a project have that much inheritance, if your model needs that kind of performance then you can pull out some of the plugin code and implement it in your model definition. The mixins would be for convenience and speed of setup, but if you're really trying to dig in, you can subvert a long chain! Compose, compose |
I've been worried about the practical constructor (because I fetch large data sets at time), and what you're saying is that v8 should handle this pretty well. I did a super fast bench test which would suggest I'm wrong.
If anything, local OS scheduler is what adds the flux. 'use strict'
// npm i --save-dev benchmark microtime
const Benchmark = require('benchmark')
const suite = new Benchmark.Suite()
const MixinOne = (Base) => class extends Base {}
const MixinTwo = (Base) => class extends Base {}
const MixinThree = (Base) => class extends Base {}
const MixinFour = (Base) => class extends Base {}
class Base {}
class MyModelOne extends MixinOne(Base) {}
class MyModelTwo extends MixinTwo(MixinOne(Base)) {}
class MyModelThree extends MixinThree(MixinTwo(MixinOne(Base))) {}
class MyModelFour extends MixinFour(MixinThree(MixinTwo(MixinOne(Base)))) {}
let model
const n = 10000
suite.add('1 mixin', function () {
model = undefined
for (let i; i < n; i++)
model = new MyModelOne()
}).add('2 mixin', function () {
model = undefined
for (let i; i < n; i++)
model = new MyModelTwo()
}).add('3 mixin', function () {
model = undefined
for (let i; i < n; i++)
model = new MyModelThree()
}).add('4 mixin', function () {
model = undefined
for (let i; i < n; i++)
model = new MyModelFour()
}).on('complete', function() {
console.log('Fastest is ' + this.filter('fastest').map('name'));
}).on('cycle', function(event) {
console.log(String(event.target));
}).run({ 'async': true }) |
I thought even this would have significant overhead. My bad, I withdraw my opinions. :) |
Wow I didn't expect such a turnaround, hahaa! Cheers! I like this approach. That's also a good idea, @WilliamSWoodruff– if one really needed to, there's likely a way you could compose a bunch of mixins with a nil class then hoist-up all the deep prototype properties to a single prototype. @fl0w thanks for going through the trouble of testing that out! I certainly wasn't sure of the perf characteristics. |
Since you don't do anything with the result |
Oh, right ... I'm horrible at |
I'm not sure if that's the case, but it's possible (not you being a horrible tester, but the thing I suggested before 😄). Also console.logging may be a bad idea. It will probably dominate the runtime. Maybe just return the sum? And thanks for doing this! |
Yea, I did class Base {
foo () {
return Math.random()
}
}
...
let bar = 0
for (let i; i < n; i++)
bar = bar + new MyModelOne().foo()
return bar
... And see no significant performance difference. I might need to investigate how to actually benchmark mixins without v8 magically fixing the problems I try to create. |
It just may be that v8 is able to magically optimize away the useless constructors, which is an excellent thing! |
Ahhh V8, what a magical beast... |
Well, all things considered, mixins seems like what you're leaning towards @koskimas? Though, extending Model is not as powerful as first suggestion (monkey patching objection). |
So I disabled v8 optimisation using %NeverOptimizeFunction (which I assume catches class-sugar as well), %NeverOptimizeFunction(Base)
%NeverOptimizeFunction(MixinOne)
// ... all mixins
%NeverOptimizeFunction(MyModelOne)
// ... all models
%NeverOptimizeFunction(constructOne)
// ... all wrappers
function constructOne {
return (new MyModelOne().foo())
}
// suite goes below, here And I still see no practical difference 1 mixin x 71,101,984 ops/sec ±3.87% (76 runs sampled)
2 mixin x 70,258,613 ops/sec ±4.50% (76 runs sampled)
3 mixin x 75,259,944 ops/sec ±1.44% (85 runs sampled)
4 mixin x 74,432,489 ops/sec ±1.20% (80 runs sampled)
5 mixin x 75,167,793 ops/sec ±1.28% (81 runs sampled) Ps, I had to downgrade from node v7 to v6 as |
OMG, I caught my mistake. https://gist.github.com/fl0w/932fd6dc2ef1c2964fdcb563f9a18479 // node v6.10.x
// 1
1 mixin x 154 ops/sec ±0.79% (73 runs sampled)
2 mixin x 117 ops/sec ±1.62% (70 runs sampled)
3 mixin x 83.11 ops/sec ±1.23% (67 runs sampled)
4 mixin x 72.57 ops/sec ±6.86% (58 runs sampled)
5 mixin x 69.06 ops/sec ±1.18% (67 runs sampled)
// 2
1 mixin x 157 ops/sec ±0.84% (75 runs sampled)
2 mixin x 112 ops/sec ±5.89% (68 runs sampled)
3 mixin x 93.77 ops/sec ±4.50% (65 runs sampled)
4 mixin x 78.99 ops/sec ±1.59% (65 runs sampled)
5 mixin x 68.02 ops/sec ±1.15% (66 runs sampled)
// 3
Just Base x 715 ops/sec ±1.37% (82 runs sampled)
0 mixin (extend Base) x 216 ops/sec ±5.02% (79 runs sampled)
1 mixin x 117 ops/sec ±8.76% (64 runs sampled)
2 mixin x 109 ops/sec ±4.48% (66 runs sampled)
3 mixin x 85.55 ops/sec ±7.00% (63 runs sampled)
4 mixin x 78.62 ops/sec ±0.88% (64 runs sampled)
5 mixin x 65.99 ops/sec ±1.00% (65 runs sampled)
// node v4.x and v7.7.x comes with a significant drop which should be related to v8
// hopefully also fixed in next major v8 performance release (v8 v5.7) EDIT It looks like the performance boost you found by removing BaseModel/Model is justified by this benchmark. Worth considering before throwing mixins at this. In the end, as far as I'm considered, I'd go with the old saying "composition over inheritance looks to be practical in node.js as well". |
@fl0w Thanks for doing these benchmarks, they're super important for this project especially! I think whatever conclusion it brings should be laid out in the docs when the 'plugins' feature is announced |
Now we are seeing some difference there! Hard to say how significant that is though. Even the five mixins case is still 6.8 million constructions per second. That's quite a bit of rows. I'll run my performance tests suite with different depths of native inheritance too. I'll post the results here. |
Fast enough for me! For those with extreme performance concerns there are probably tricky ways to collapse multiple mixins into a single mixin. I think it's well worth it for the ergonomics. And I know yall enjoy decorators, which will work with this approach too ;) Also, strangely getting different results on runkit https://runkit.com/devinivy/58d13de053f6500014769d07 I did change the |
I just ran a With babel transpiled inheritance there was a 20 percent slowdown. I think we can now conclude that performance is not an issue with mixins ∎ |
@koskimas nicely done, there is however a noticeable performance hit in node v7.7.3 (using v8 v5.5), compared to node v6.10.x and v4.x.x (latest LTS). Unrelated, and I'll assume this gets corrected once v8 v5.7 hits shelves (secretly hoping for node v8 release, wouldn't count on it). |
Most things in objection are classes, so there isn't much difference. Some things like the Anyway we are not forcing people to use mixins, just encouraging. They can still create monkey patching plugins if they want, nothing is stopping them. |
What's the next step with this? |
I'll write the plugin best practices section to the docs and add a place for a list of known plugins and packages using objection. I'll add the ones mentioned here to the list. More can be added through pull requests or issues. I'll also add a shortcut link to the plugin/package list from the README. I will also write a contribution guide while I'm at it. |
Awesome! 👍 |
I can likely provide support for mixin style while maintaining backwards compatibility. I would like to point out, though, that mixins can be a little awkward, especially as the list of mixins grows and if configuration is required. Mixin // A little redundant...
class Foo extends softDelete(timestamp(Model)) {
static get timestampAttributes() {
return {
create: 'createdOn',
update: 'updatedOn'
}
}
}
// The syntax that gives me nightmares
class Bar extends softDelete(timestamp(Model, { create: 'createdOn', update: 'updatedOn' })) {} Decoration // ES6
class User extends Model {}
softDelete()(User);
timestamp({
create: 'createdOn',
update: 'updatedOn'
})(User);
// ES-next/TS Decorators
@softDelete()
@timestamp({
create: 'createdOn',
update: 'updatedOn'
})
class User extends Model {} I'm not staunchly advocating for this here, just wanted to bring it up for consideration. I'll go with the flow on the final decision. |
I think there should be some solution available for those using "bare" node. Since the implementation of a decorator can be constructed from an existing mixin, I don't think there's any major conflict! |
@ackerdev Dammit, that's also a good point 😄 You don't have to use the nightmare inducing syntax though. You can do this: let BaseClass = timestamp(Model, {
create: 'createdOn',
update: 'updatedOn'
});
BaseClass = softDelete(BaseClass);
class Bar extends BaseClass {} It's not that different from the ES6 decorator case. @devinivy There is a solution for bare node: the ES6 example @ackerdev provided. Can a decorator be constructed from a mixin? For that to work, the extended class must be returned from the decorator and reassigned to the constructor variable. I don't think that's possible. I deed to read the spec more carefully. What I love about the mixin approach is the user's choice and composability. We would also have all that with decorators. I'm not saying I changed my mind again but, this is definitely something we should consider. I don't know why I didn't think of this, being a decorated decorator user. |
I was wrong, it is possible to construct a decorator from a mixin. In fact, mixins are decorators! From the spec: @F("color")
@G
class Foo {
} Desugaring (ES6)var Foo = (function () {
class Foo {
}
Foo = F("color")(Foo = G(Foo) || Foo) || Foo;
return Foo;
})(); With the difference that if you do this: const MyMixin = (BaseClass) {
return class ClassyClass extends BaseClass {}
}
@MyMixin
class Person extends Model {
} your |
You still need a separate decorator-factory interface to support decorators if you provide configuration options, though, right? Because the proposal shows decorators defined with options like so, a factory that returns a decorator function... @F({ foo: 'bar' })
class Person extends Model {}
function F(options) {
return function(target) {
target._F = options;
}
} I'm fine with this, personally, though I think it should be added to the best practices if we want to go this route that plugin developers should try to support both interfaces in this manner, and suggest a common format to do so. Maybe something like: function softDelete(target, options) {
return class extends target {
delete() {
// do things
}
}
}
module.exports = {
mixin: softDelete
decorator: options => target => softDelete(target, options)
} Although if you have a mixin that needs no configuration then both would be the same... so I'm not sure what is the best way about this. |
Nope, decorator can be any expression that evaluates to a function. So a function call that returns a function (your example) and just a function are both valid expressions. The spec says:
|
I personally don't think it should be up to a plugin author to have to think about decorators, mostly because not everyone uses them (they aren't part of nodejs today) and mixins are general enough– if someone wants to use their mixin as a decorator they can easily make that transformation. At worst I think the arguments might want to be flipped! |
At its simplest decorator is nothing but a function that takes a constructor function as its only parameter and optionally returns another (or the same) constructor. There is absolutely nothing they need to think about but that. It's even simpler than a Mixin function that needs to return a subclass. |
Sounds like a mixin– I think we're all good ;) :) |
To sum up a gitter conversation about interoperability of decorators and mixins: Mixins can be easily curried into decorators especially if options are given as the first argument. class User extends Mixin1(mixin1Opts, Mixin2(mixin2Opts, Model)) {}
@curry(Mixin1)(mixin1Opts)
@curry(Mixin2)(mixin2Opts)
class User extends Model {
} With this in mind, mixins seem like the best combination of "works now" and "future proof". |
One more thing: Maybe the options should be the second argument after all? Using the |
After some additional research I ended up with mixins that only take one argument: the class to extend. Options should be passed using a factory function like this: module.exports = (options) => {
return (Model) => {
return class Foo extends Model {
};
};
}; This keeps the mixins simple and supports the decorator case without modification. There is no commonly agreed specification for mixins. Taking only one argument keeps them compatible with libraries like mixwith. Also i think developers are familiar with the factory function pattern from things like expressjs middleware. By the way, this article from the author of mixwith is worth reading. I started the documentation work by creating a couple of example plugins. Please check them out and help me improve them. https://github.com/Vincit/objection.js/tree/master/examples |
Is there any desire to provide an official plugin listing? I've created two plugins which others may benefit from: objection-timestamp and objection-softdelete.
The text was updated successfully, but these errors were encountered: