-
-
Notifications
You must be signed in to change notification settings - Fork 16
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
feat(transformer): Support circular interface extensions #389
base: master
Are you sure you want to change the base?
feat(transformer): Support circular interface extensions #389
Conversation
0c5b829
to
99cb2de
Compare
Hi @martinjlowm. @Pmyl and I had a look at your changes. This is great work. Pmyl found a scenario where this implementation will not work. Could you add a test ?
the reason this is not working its because generic are cached and re used. I think in your implementation you have to make sure the value w will be cached correctly. I can have a look later but I'm sure you'll find the solution :P
|
I see! So, the fix is easy, but I'd like some thoughts on some naming. The problem lies here: ts-auto-mock/src/transformer/genericDeclaration/genericDeclaration.ts Lines 160 to 162 in 57727e6
The mock key for the new scope may conflict with other parallel scopes and the fix is as simple as: const constructedDeclarationKey = `${declarationKey}_C`;
if (!typeParameterDeclaration || scope.currentMockKey !== constructedDeclarationKey) {
genericValueDescriptor = GetDescriptor(genericNode, new Scope(constructedDeclarationKey));
} Which will uniquely identify the scope with the appended Alternatively, we may also extend if (!typeParameterDeclaration || !scope.isBound()) {
genericValueDescriptor = GetDescriptor(genericNode, (new Scope(declarationKey)).bind());
} What do You think? |
I've found another problem with a much simpler example. export class ClassWithGenerics<T> {
public a: T;
}
interface A extends ClassWithGenerics<A> {
b: number;
}
createMock<A>(); This is the generated code: ɵRepository.ɵRepository.instance.getFactory("@A_1")([{
i: ["@ClassWithGenerics_1T"],
w: function () {
return new function () {
Object.assign(this, ɵRepository.ɵRepository.instance.getFactory("@A_1")([{
i: ["@ClassWithGenerics_1T"],
w: function () {
return new this.constructor;
}
}]));
};
}
}]); This is your test: expect(propertiesA.a.b).toBe(0); That hits only the code with expect(propertiesA.a.a.b).toBe(0); That will fail because |
Right, that makes sense - I didn't realize class properties were emitted differently to interface properties. |
I’m looking forward the new solution, I was positively surprised by how smart this solution is, too bad it doesn’t work for every case |
Utilize constructors to support circular generics for instantiable types. If the transformer experiences circular generics, the scope descriptor parameter is now used to preserve a nested state to avoid looping descriptors forever. The scope enables the generic descriptor to determine whether to emit a new instance of the extension or to reuse the parent's constructor (which would emit an instance of the same prototype), i.e.: ``` getFactory("@factory")([{ i: ["@parameterId"], w: function () { return new function () { Object.assign(this, ɵRepository.ɵRepository.instance.getFactory("@factory")([{ i: ["@parameterId"], w: function () { return new this.constructor; } }])); }; } }]) ```
…since such keys may be cached and reused in parallel A bound state is an indicator whether a new function instance is emitted, binding this in the current scope. The scope's constructor can then be referenced with `this.constructor` enabling a reference to the parent constructor.
57727e6
to
aa5285f
Compare
…etween two interfaces
It appears there was a couple of issues:
I went ahead and bumped the test to cover the actual nesting. I didn't realize that I had previously tested by referencing the non-generic properties which is completely irrelevant to this PR 🤦 |
@@ -110,10 +167,15 @@ export function GenericDeclaration(scope: Scope): IGenericDeclaration { | |||
} | |||
} | |||
|
|||
if (!typeParameterDeclaration || !scope.isBound()) { |
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.
It may be a dumb question but I still don't understand why the previous if statement
is not good enough to make sure is a circular generic dependency?
I mean this const isExtendingItself: boolean = MockDefiner.instance.getDeclarationKeyMap(typeParameterDeclaration) === declarationKey;
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.
The old condition was fine if you were to completely ignore circular generics and terminate early.
However, this time around, we want to process the declaration exactly twice, where the second iteration avoids calling GetDescriptor
(which is what's causing the infinite recursion) and instead utilizes this.constructor
for a back-reference.
isExtendingItself
does not help us in that case if we were to use it to our advantage, since the type checker would bring us to the same declaration on every GetDescriptor
call and result in the same scenario, yes, it is extending itself, over and over.
I have few comments on it but before leaving them I'm spending a bit of time understanding the reason behind every change. At the moment the behaviour seems fine but I'll let you know when I read and ran everything locally, sorry for the delay |
Sure, take your time :) |
I had some time to test some edge cases and I noticed something interesting in the generated code: In case of a double circular dependency interface GenC<T> {
c: T;
}
interface GenB<T> extends GenC<GenB<T>> {
b: T;
}
interface A extends GenB<A> {
a: A;
value: string;
} there is a piece of generated code that goes like this [...]
Object.defineProperties(m, {
"a": {
get: function () {
return d.hasOwnProperty("a") ? d["a"] : d["a"] = ɵRepository.ɵRepository.instance.getFactory("@A_1")([{
i: ["@GenB_1T"],
w: function () {
return new function () {
var that = this;
Object.defineProperties(this, Object.getOwnPropertyDescriptors(ɵRepository.ɵRepository.instance.getFactory("@A_1")([{
i: ["@GenB_1T"],
w: function () {
return new that.constructor;
}
}, {
i: ["@GenC_1T"],
w: function () {
return new that.constructor;
}
}])));
};
}
}, {
[...] In these generics But in general I would like to make sure that these (currently failing) tests pass correctly it('should work', () => {
interface GenC<T> {
c: T;
}
interface GenB<T> extends GenC<GenB<T>> {
b: T;
}
interface A extends GenB<A> {
a: A;
value: string;
}
const type: A = createMock<A>();
expect((type.a.c.b as unknown as A).value).not.toEqual('');
expect((type.a.b as unknown as A).value).not.toEqual('');
expect((type.a.b.c as unknown as A).value).not.toEqual('');
expect((type.b.c as unknown as A).value).not.toEqual('');
expect((type.b as unknown as A).value).not.toEqual('');
}); As I said this can be a non-issue if nobody casts anything to unknown/any but I didn't spent much time to think of worse outcomes. |
I just found a more problematic case: interface GenC<T> {
c: T;
}
interface GenB<T> {
b: T;
}
interface B {
valueB: string;
}
interface A extends GenB<A>, GenC<B> {
a: A;
valueA: string;
}
const type: A = createMock<A>();
expect((type.a.b.c as unknown as A).valueA).not.toEqual(''); //fail
expect(type.a.b.c.valueB).toEqual(''); //fail
expect((type.b.c as unknown as A).valueA).not.toEqual(''); //fail
expect(type.b.c.valueB).toEqual(''); //fail Generated code: var type = ɵRepository.ɵRepository.instance.getFactory("@A_1")([{
i: ["@GenB_1T"],
w: function () {
return new function () {
var that = this;
Object.defineProperties(this, Object.getOwnPropertyDescriptors(ɵRepository.ɵRepository.instance.getFactory("@A_1")([{
i: ["@GenB_1T"],
w: function () {
return new that.constructor;
}
}, {
i: ["@GenC_1T"],
w: function () {
return new that.constructor;
}
}])));
};
}
}, {
i: ["@GenC_1T"],
w: function () {
return new function () {
var that = this;
Object.defineProperties(this, Object.getOwnPropertyDescriptors(ɵRepository.ɵRepository.instance.getFactory("@B_1")([])));
};
}
}]); As you can see the generic I'm not sure why this happens really, I think it's something about the scope... unfortunately this feature is more complex than expected |
I see! Nice that you found some complex test cases for this. In general, in both examples, it seems we need to apply some extra logic to this "bind"-scope behavior - a simple scope check is not enough since the generic variable may change in that same iteration, in the case where there are more than a single extension applied. I hope to find some time over the weekend to test against those two examples and find a solution. I will keep you posted :) |
Just an update - I had a look at these extra cases yesterday and I'm close to a solution - what's left, is being able to distinguish the |
51a73c1
to
b8fa5ce
Compare
454713f solves the issue you discovered @Pmyl - I rewrote the extends test to cover those cases and a range of parallel circular extensions. edit: That was perhaps a bit too quick I commented - discovered some cases that weren't covered. |
A quick update. With the two most recent commits, the emitted code for a fairly complex extension configuration is as follows: ɵRepository.ɵRepository.instance.registerFactory("@B_1", function (t) { return (function () { var d = {}, m = { "b": "" }; Object.defineProperties(m, {
"A": { get: function () { return d.hasOwnProperty("A") ? d["A"] : d["A"] = ɵRepository.ɵRepository.instance.getFactory("@A_1")([{
i: ["@GenericC_1T"],
w: function () { return new function () { var ɵA_1_GenericC_1T = this; Object.defineProperties(this, Object.getOwnPropertyDescriptors(ɵRepository.ɵRepository.instance.getFactory("@A_1")([{
i: ["@GenericC_1T"],
w: function () { return new ɵA_1_GenericC_1T.constructor; }
}, {
i: ["@GenericD_1T"],
w: function () { return new function () { var ɵA_1_GenericD_1T = this; Object.defineProperties(this, Object.getOwnPropertyDescriptors(ɵRepository.ɵRepository.instance.getFactory("@A_1")([{
i: ["@GenericC_1T"],
w: function () { return new ɵA_1_GenericC_1T.constructor; }
}, {
i: ["@GenericD_1T"],
w: function () { return new ɵA_1_GenericD_1T.constructor; }
}, {
i: ["@GenericE_1T"],
w: function () { return new function () { var ɵA_1_GenericE_1T = this; Object.defineProperties(this, Object.getOwnPropertyDescriptors(ɵRepository.ɵRepository.instance.getFactory("@B_1")([{
i: ["@GenericC_1T"],
w: function () { return new function () { var ɵB_1_GenericC_1T = this; Object.defineProperties(this, Object.getOwnPropertyDescriptors(ɵRepository.ɵRepository.instance.getFactory("@B_1")([{
i: ["@GenericC_1T"],
w: function () { return new ɵB_1_GenericC_1T.constructor; }
}, {
i: ["@GenericD_1T"],
w: function () { return new function () { var ɵB_1_GenericD_1T = this; Object.defineProperties(this, Object.getOwnPropertyDescriptors(ɵRepository.ɵRepository.instance.getFactory("@A_1")([{
i: ["@GenericC_1T"],
w: function () { return new ɵA_1_GenericC_1T.constructor; }
}, {
i: ["@GenericD_1T"],
w: function () { return new ɵA_1_GenericD_1T.constructor; }
}, {
i: ["@GenericE_1T"],
w: function () { return new ɵA_1_GenericE_1T.constructor; }
}]))); }; }
}, {
i: ["@GenericE_1T"],
w: function () { return new function () { var ɵB_1_GenericE_1T = this; Object.defineProperties(this, Object.getOwnPropertyDescriptors(ɵRepository.ɵRepository.instance.getFactory("@A_1")([{
i: ["@GenericC_1T"],
w: function () { return new ɵA_1_GenericC_1T.constructor; }
}, {
i: ["@GenericD_1T"],
w: function () { return new ɵA_1_GenericD_1T.constructor; }
}, {
i: ["@GenericE_1T"],
w: function () { return new ɵA_1_GenericE_1T.constructor; }
}]))); }; }
}]))); }; }
}, {
... The naming of Since these constructor references are only valid for the current scope, this quickly builds up a lot of extra code, that I think can be reused to some degree. At least the scope functionality is there, so registration of cached functions should be fairly straightforward to implement. Currently, There's still at least one uncovered case I know of: interface A extends GenericC<A>, GenericD<A>, GenericE<B>, GenericFG<A, B> {
a: number;
B: B;
} Here, |
Utilize constructors to support circular generics for instantiable types.
If the transformer experiences circular generics, the scope descriptor parameter
is now used to preserve a nested state to avoid looping descriptors forever. The
scope enables the generic descriptor to determine whether to emit a new instance
of the extension or to reuse the parent's constructor (which would emit an
instance of the same prototype), i.e.:
I just went ahead and opened this here (cc: @uittorio) - it's easier than having a commit hash lying around in some random conversation.