[IMP] web: add es6 class patcher

This commit implements a new way to monkey-patch ES6
classes (for instance, Owl Components).

Usage
-----

odoo.module('module.BaseClass', function (require) {
    const patchMixin = require('web.patchMixin');

    // Define your class as usual
    class BaseClass {
        constructor() {
            this.foo = 'foo';
        }
        bar() {
            console.log(this.foo);
        }
    }

    // but export it like this to have a "patchable" version
    return patchMixin(BaseClass);
});

odoo.module('module.Patch', function (require) {
    // imports the "patchable" version
    const BaseClass = require('module.BaseClass');

    BaseClass.patch('key', T =>
        class extends T {
            constructor() {
                super();
                this.foo = 'patched foo';
            }
            bar() {
                super.bar();
                console.log('patched bar()');
            }
        }
    );
});

odoo.module('module.instance', function (require) {
    // imports the "patchable" version
    const BaseClass = require('module.BaseClass');

    const obj = new BaseClass();
    obj.bar();
    // if module.patch is loaded before this module the patch
    // will be apply and calling obj.bar() will log:
    // patched foo
    // patched bar()

    // You can unpatch a patchable class
    BaseClass.unpatch('key');
});
This commit is contained in:
Michael Mattiello (mcm)
2020-04-16 07:19:25 +00:00
parent 5cc42022fc
commit dcf9a4c38d
4 changed files with 1082 additions and 1 deletions
@@ -0,0 +1,80 @@
odoo.define("web.patchMixin", function () {
"use strict";
/**
* This module defines and exports the 'patchMixin' function. This function
* returns a 'monkey-patchable' version of the ES6 Class given in arguments.
*
* const patchMixin = require('web.patchMixin');
* class MyClass {
* print() {
* console.log('MyClass');
* }
* }
* const MyPatchedClass = patchMixin(MyClass);
*
*
* A patchable class has a 'patch' function, allowing to define a patch:
*
* MyPatchedClass.patch("module_name.key", T =>
* class extends T {
* print() {
* console.log('MyPatchedClass');
* super.print();
* }
* }
* );
*
* const myPatchedClass = new MyPatchedClass();
* myPatchedClass.print(); // displays "MyPatchedClass" and "MyClass"
*
*
* The 'unpatch' function can be used to remove a patch, given its key:
*
* MyPatchedClass.unpatch("module_name.key");
*/
function patchMixin(OriginalClass) {
let unpatchList = [];
class PatchableClass extends OriginalClass {}
PatchableClass.patch = function (name, patch) {
if (unpatchList.find(x => x.name === name)) {
throw new Error(`Class ${OriginalClass.name} already has a patch ${name}`);
}
if (!Object.prototype.hasOwnProperty.call(this, 'patch')) {
throw new Error(`Class ${this.name} is not patchable`);
}
const SubClass = patch(Object.getPrototypeOf(this));
unpatchList.push({
name: name,
elem: this,
prototype: this.prototype,
origProto: Object.getPrototypeOf(this),
origPrototype: Object.getPrototypeOf(this.prototype),
patch: patch,
});
Object.setPrototypeOf(this, SubClass);
Object.setPrototypeOf(this.prototype, SubClass.prototype);
};
PatchableClass.unpatch = function (name) {
if (!unpatchList.find(x => x.name === name)) {
throw new Error(`Class ${OriginalClass.name} does not have any patch ${name}`);
}
const toUnpatch = unpatchList.reverse();
unpatchList = [];
for (let unpatch of toUnpatch) {
Object.setPrototypeOf(unpatch.elem, unpatch.origProto);
Object.setPrototypeOf(unpatch.prototype, unpatch.origPrototype);
}
for (let u of toUnpatch.reverse()) {
if (u.name !== name) {
PatchableClass.patch(u.name, u.patch);
}
}
};
return PatchableClass;
}
return patchMixin;
});
+6 -1
View File
@@ -527,7 +527,12 @@ var utils = {
/**
* Patch a class and return a function that remove the patch
* when called.
*
*
* This function is the last resort solution for monkey-patching an
* ES6 Class, for people that do not control the code defining the Class
* to patch (e.g. partners), and when that Class isn't patchable already
* (i.e. when it doesn't have a 'patch' function, defined by the 'web.PatchMixin').
*
* @param {Class} C Class to patch
* @param {string} patchName
* @param {Object} patch
@@ -0,0 +1,994 @@
odoo.define("web.patchMixin_tests", function (require) {
"use strict";
const patchMixin = require('web.patchMixin');
QUnit.module('core', {}, function () {
QUnit.module('patchMixin', {}, function () {
QUnit.test('basic use', function (assert) {
assert.expect(4);
const A = patchMixin(
class {
constructor() {
assert.step('A.constructor');
}
f() {
assert.step('A.f');
}
}
);
const a = new A();
a.f();
assert.ok(a instanceof A);
assert.verifySteps([
'A.constructor',
'A.f',
]);
});
QUnit.test('simple patch', function (assert) {
assert.expect(5);
const A = patchMixin(
class {
constructor() {
assert.step('A.constructor');
}
f() {
assert.step('A.f');
}
}
);
A.patch('patch', T =>
class extends T {
constructor() {
super();
assert.step('patch.constructor');
}
f() {
super.f();
assert.step('patch.f');
}
}
);
(new A()).f();
assert.verifySteps([
'A.constructor',
'patch.constructor',
'A.f',
'patch.f',
]);
});
QUnit.test('two patches on same base class', function (assert) {
assert.expect(7);
const A = patchMixin(
class {
constructor() {
assert.step('A.constructor');
}
f() {
assert.step('A.f');
}
}
);
A.patch('patch1', T =>
class extends T {
constructor() {
super();
assert.step('patch1.constructor');
}
f() {
super.f();
assert.step('patch1.f');
}
}
);
A.patch('patch2', T =>
class extends T {
constructor() {
super();
assert.step('patch2.constructor');
}
f() {
super.f();
assert.step('patch2.f');
}
}
);
(new A()).f();
assert.verifySteps([
'A.constructor',
'patch1.constructor',
'patch2.constructor',
'A.f',
'patch1.f',
'patch2.f',
]);
});
QUnit.test('two patches with same name on same base class', function (assert) {
assert.expect(1);
const A = patchMixin(class {});
A.patch('patch', T => class extends T {});
// keys should be unique
assert.throws(() => {
A.patch('patch', T => class extends T {});
});
});
QUnit.test('unpatch', function (assert) {
assert.expect(8);
const A = patchMixin(
class {
constructor() {
assert.step('A.constructor');
}
f() {
assert.step('A.f');
}
}
);
A.patch('patch', T =>
class extends T {
constructor() {
super();
assert.step('patch.constructor');
}
f() {
super.f();
assert.step('patch.f');
}
}
);
(new A()).f();
assert.verifySteps([
'A.constructor',
'patch.constructor',
'A.f',
'patch.f',
]);
A.unpatch('patch');
(new A()).f();
assert.verifySteps([
'A.constructor',
'A.f',
]);
});
QUnit.test('unpatch 2', function (assert) {
assert.expect(12);
const A = patchMixin(
class {
constructor() {
assert.step('A.constructor');
}
f() {
assert.step('A.f');
}
}
);
A.patch('patch1', T =>
class extends T {
constructor() {
super();
assert.step('patch1.constructor');
}
f() {
super.f();
assert.step('patch1.f');
}
}
);
A.patch('patch2', T =>
class extends T {
constructor() {
super();
assert.step('patch2.constructor');
}
f() {
super.f();
assert.step('patch2.f');
}
}
);
(new A()).f();
assert.verifySteps([
'A.constructor',
'patch1.constructor',
'patch2.constructor',
'A.f',
'patch1.f',
'patch2.f',
]);
A.unpatch('patch1');
(new A()).f();
assert.verifySteps([
'A.constructor',
'patch2.constructor',
'A.f',
'patch2.f',
]);
});
QUnit.test('unpatch inexistent', function (assert) {
assert.expect(1);
const A = patchMixin(class {});
A.patch('patch', T => class extends T {});
A.unpatch('patch');
assert.throws(() => {
A.unpatch('inexistent-patch');
});
});
QUnit.test('patch for specialization', function (assert) {
assert.expect(1);
let args = [];
const A = patchMixin(
class {
constructor() {
args = ['A', ...arguments];
}
}
);
A.patch('patch', T =>
class extends T {
constructor() {
super('patch', ...arguments);
}
}
);
new A('instantiation');
assert.deepEqual(args, ['A', 'patch', 'instantiation']);
});
QUnit.test('instance fields', function (assert) {
assert.expect(1);
const A = patchMixin(
class {
constructor() {
this.x = ['A'];
}
}
);
A.patch('patch', T =>
class extends T {
constructor() {
super();
this.x.push('patch');
}
}
);
const a = new A();
assert.deepEqual(a.x, ['A', 'patch']);
});
QUnit.test('call instance method defined in patch', function (assert) {
assert.expect(3);
const A = patchMixin(
class {}
);
assert.notOk((new A()).f);
A.patch('patch', T =>
class extends T {
f() {
assert.step('patch.f');
}
}
);
(new A()).f();
assert.verifySteps(['patch.f']);
});
QUnit.test('class methods', function (assert) {
assert.expect(7);
const A = patchMixin(
class {
static f() {
assert.step('A');
}
}
);
A.f();
assert.verifySteps(['A']);
A.patch('patch', T =>
class extends T {
static f() {
super.f();
assert.step('patch');
}
}
);
A.f();
assert.verifySteps(['A', 'patch']);
A.unpatch('patch');
A.f();
assert.verifySteps(['A']);
});
QUnit.test('class fields', function (assert) {
assert.expect(4);
class A {}
A.foo = ['A'];
A.bar = 'A';
const PatchableA = patchMixin(A);
PatchableA.patch('patch', T => {
class Patch extends T {}
Patch.foo = [...T.foo, 'patched A'];
Patch.bar = 'patched A';
return Patch;
});
assert.deepEqual(PatchableA.foo, ['A', 'patched A']);
assert.strictEqual(PatchableA.bar, 'patched A');
PatchableA.unpatch('patch');
assert.deepEqual(PatchableA.foo, ['A']);
assert.strictEqual(PatchableA.bar, 'A');
});
QUnit.test('lazy patch', function (assert) {
assert.expect(4);
const A = patchMixin(
class {
constructor() {
assert.step('A.constructor');
}
f() {
assert.step('A.f');
}
}
);
const a = new A();
A.patch('patch', T =>
class extends T {
constructor() {
super();
// will not be called
assert.step('patch.constructor');
}
f() {
super.f();
assert.step('patch.f');
}
}
);
a.f();
assert.verifySteps([
'A.constructor',
'A.f',
'patch.f',
]);
});
QUnit.module('inheritance');
QUnit.test('inheriting a patchable class', function (assert) {
assert.expect(8);
const A = patchMixin(
class {
constructor() {
assert.step('A.constructor');
}
f() {
assert.step('A.f');
}
}
);
class B extends A {
constructor() {
super();
assert.step('B.constructor');
}
f() {
super.f();
assert.step('B.f');
}
}
(new A()).f();
assert.verifySteps([
'A.constructor',
'A.f',
]);
(new B()).f();
assert.verifySteps([
'A.constructor',
'B.constructor',
'A.f',
'B.f',
]);
});
QUnit.test('inheriting a patchable class that has patch', function (assert) {
assert.expect(12);
const A = patchMixin(
class {
constructor() {
assert.step('A.constructor');
}
f() {
assert.step('A.f');
}
}
);
A.patch('patch', T =>
class extends T {
constructor() {
super();
assert.step('patch.constructor');
}
f() {
super.f();
assert.step('patch.f');
}
}
);
class B extends A {
constructor() {
super();
assert.step('B.constructor');
}
f() {
super.f();
assert.step('B.f');
}
}
(new A()).f();
assert.verifySteps([
'A.constructor',
'patch.constructor',
'A.f',
'patch.f',
]);
(new B()).f();
assert.verifySteps([
'A.constructor',
'patch.constructor',
'B.constructor',
'A.f',
'patch.f',
'B.f',
]);
});
QUnit.test('patch inherited patchable class', function (assert) {
assert.expect(10);
const A = patchMixin(
class {
constructor() {
assert.step('A.constructor');
}
f() {
assert.step('A.f');
}
}
);
const B = patchMixin(
class extends A {
constructor() {
super();
assert.step('B.constructor');
}
f() {
super.f();
assert.step('B.f');
}
}
);
B.patch('patch', T =>
class extends T {
constructor() {
super();
assert.step('patch.constructor');
}
f() {
super.f();
assert.step('patch.f');
}
}
);
(new A()).f();
assert.verifySteps([
'A.constructor',
'A.f',
]);
(new B()).f();
assert.verifySteps([
'A.constructor',
'B.constructor',
'patch.constructor',
'A.f',
'B.f',
'patch.f',
]);
});
QUnit.test('patch inherited patched class', function (assert) {
assert.expect(14);
const A = patchMixin(
class {
constructor() {
assert.step('A.constructor');
}
f() {
assert.step('A.f');
}
}
);
A.patch('patch', T =>
class extends T {
constructor() {
super();
assert.step('A.patch.constructor');
}
f() {
super.f();
assert.step('A.patch.f');
}
}
);
/**
* /!\ WARNING /!\
*
* If you want to patch class B, make it patchable
* otherwise it will patch class A!
*/
const B = patchMixin(
class extends A {
constructor() {
super();
assert.step('B.constructor');
}
f() {
super.f();
assert.step('B.f');
}
}
);
B.patch('patch', T =>
class extends T {
constructor() {
super();
assert.step('B.patch.constructor');
}
f() {
super.f();
assert.step('B.patch.f');
}
}
);
const a = new A();
a.f();
assert.verifySteps([
'A.constructor',
'A.patch.constructor',
'A.f',
'A.patch.f',
]);
const b = new B();
b.f();
assert.verifySteps([
'A.constructor',
'A.patch.constructor',
'B.constructor',
'B.patch.constructor',
'A.f',
'A.patch.f',
'B.f',
'B.patch.f',
]);
});
QUnit.test('unpatch inherited patched class', function (assert) {
assert.expect(15);
const A = patchMixin(
class {
constructor() {
assert.step('A.constructor');
}
f() {
assert.step('A.f');
}
}
);
A.patch('patch', T =>
class extends T {
constructor() {
super();
assert.step('A.patch.constructor');
}
f() {
super.f();
assert.step('A.patch.f');
}
}
);
const B = patchMixin(
class extends A {
constructor() {
super();
assert.step('B.constructor');
}
f() {
super.f();
assert.step('B.f');
}
}
);
B.patch('patch', T =>
class extends T {
constructor() {
super();
assert.step('B.patch.constructor');
}
f() {
super.f();
assert.step('B.patch.f');
}
}
);
A.unpatch('patch');
(new A()).f();
assert.verifySteps([
'A.constructor',
'A.f',
]);
(new B()).f();
assert.verifySteps([
'A.constructor',
'B.constructor',
'B.patch.constructor',
'A.f',
'B.f',
'B.patch.f',
]);
B.unpatch('patch');
(new B()).f();
assert.verifySteps([
'A.constructor',
'B.constructor',
'A.f',
'B.f',
]);
});
QUnit.test('unpatch inherited patched class 2', function (assert) {
assert.expect(12);
const A = patchMixin(
class {
constructor() {
assert.step('A.constructor');
}
f() {
assert.step('A.f');
}
}
);
A.patch('patch', T =>
class extends T {
constructor() {
super();
assert.step('A.patch.constructor');
}
f() {
super.f();
assert.step('A.patch.f');
}
}
);
const B = patchMixin(
class extends A {
constructor() {
super();
assert.step('B.constructor');
}
f() {
super.f();
assert.step('B.f');
}
}
);
B.patch('patch', T =>
class extends T {
constructor() {
super();
assert.step('B.patch.constructor');
}
f() {
super.f();
assert.step('B.patch.f');
}
}
);
B.unpatch('patch');
(new B()).f();
assert.verifySteps([
'A.constructor',
'A.patch.constructor',
'B.constructor',
'A.f',
'A.patch.f',
'B.f',
]);
A.unpatch('patch');
(new B()).f();
assert.verifySteps([
'A.constructor',
'B.constructor',
'A.f',
'B.f',
]);
});
QUnit.test('class methods', function (assert) {
assert.expect(12);
const A = patchMixin(
class {
static f() {
assert.step('A');
}
}
);
const B = patchMixin(
class extends A {
static f() {
super.f();
assert.step('B');
}
}
);
A.patch('patch', T =>
class extends T {
static f() {
super.f();
assert.step('A.patch');
}
}
);
B.patch('patch', T =>
class extends T {
static f() {
super.f();
assert.step('B.patch');
}
}
);
B.f();
assert.verifySteps(['A', 'A.patch', 'B', 'B.patch']);
A.unpatch('patch');
B.f();
assert.verifySteps(['A', 'B', 'B.patch']);
B.unpatch('patch');
B.f();
assert.verifySteps(['A', 'B']);
});
QUnit.test('class fields', function (assert) {
assert.expect(3);
class A {}
A.foo = ['A'];
A.bar = 'A';
const PatchableA = patchMixin(A);
class B extends PatchableA {}
// /!\ This is not dynamic
// so if A.foo is patched after this assignment
// B.foo won't have the patches of A.foo
B.foo = [...PatchableA.foo, 'B'];
B.bar = 'B';
const PatchableB = patchMixin(B);
PatchableA.patch('patch', T => {
class Patch extends T {}
Patch.foo = [...T.foo, 'patched A'];
Patch.bar = 'patched A';
return Patch;
});
PatchableB.patch('patch', T => {
class Patch extends T {}
Patch.foo = [...T.foo, 'patched B'];
Patch.bar = 'patched B';
return Patch;
});
assert.deepEqual(PatchableB.foo, [ 'A', /* 'patched A', */ 'B', 'patched B' ]);
assert.deepEqual(PatchableA.foo, [ 'A', 'patched A' ]);
assert.strictEqual(PatchableB.bar, 'patched B');
});
QUnit.test('inheritance and lazy patch', function (assert) {
assert.expect(6);
const A = patchMixin(
class {
constructor() {
assert.step('A.constructor');
}
f() {
assert.step('A.f');
}
}
);
class B extends A {
constructor() {
super();
assert.step('B.constructor');
}
f() {
super.f();
assert.step('B.f');
}
}
const b = new B();
A.patch('patch', T =>
class extends T {
constructor() {
super();
// will not be called
assert.step('patch.constructor');
}
f() {
super.f();
assert.step('patch.f');
}
}
);
b.f();
assert.verifySteps([
'A.constructor',
'B.constructor',
'A.f',
'patch.f',
'B.f',
]);
});
QUnit.test('patch not patchable class that inherits patchable class', function (assert) {
assert.expect(1);
const A = patchMixin(class {});
class B extends A {}
// class B is not patchable
assert.throws(() => {
B.patch('patch', T => class extends T {});
});
});
});
});
});
+2
View File
@@ -144,6 +144,7 @@
<script type="text/javascript" src="/web/static/src/js/core/dom.js"></script>
<script type="text/javascript" src="/web/static/src/js/core/local_storage.js"></script>
<script type="text/javascript" src="/web/static/src/js/core/mixins.js"></script>
<script type="text/javascript" src="/web/static/src/js/core/patch_mixin.js"></script>
<script type="text/javascript" src="/web/static/src/js/core/qweb.js"></script>
<script type="text/javascript" src="/web/static/src/js/core/ram_storage.js"></script>
<script type="text/javascript" src="/web/static/src/js/core/registry.js"></script>
@@ -721,6 +722,7 @@
<script type="text/javascript" src="/web/static/tests/core/data_comparison_utils_tests.js"></script>
<script type="text/javascript" src="/web/static/tests/core/math_utils_tests.js"></script>
<script type="text/javascript" src="/web/static/tests/core/mixins_tests.js"></script>
<script type="text/javascript" src="/web/static/tests/core/patch_mixin_tests.js"></script>
<script type="text/javascript" src="/web/static/tests/core/time_tests.js"></script>
<script type="text/javascript" src="/web/static/tests/core/concurrency_tests.js"></script>
<script type="text/javascript" src="/web/static/tests/core/util_tests.js"></script>