ts-auto-mock icon indicating copy to clipboard operation
ts-auto-mock copied to clipboard

feat(transformer): Support circular interface extensions

Open martinjlowm opened this issue 4 years ago • 14 comments

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; }
  }])); }; }
}])

┌────────────────────────┬────────┬──────────┬──────────┬─────────────┬──────────┬─────────┬─────────────┬──────────┬───────────┬────────────┬───────────┬────────────┬───────────┬────────────┐
│        (index)         │ Files  │  Lines   │  Nodes   │ Identifiers │ Symbols  │  Types  │ Memory used │ I/O read │ I/O write │ Parse time │ Bind time │ Check time │ Emit time │ Total time │
├────────────────────────┼────────┼──────────┼──────────┼─────────────┼──────────┼─────────┼─────────────┼──────────┼───────────┼────────────┼───────────┼────────────┼───────────┼────────────┤
│     no transformer     │ '5093' │ '112415' │ '409280' │  '127866'   │ '103042' │ '37318' │  '455358K'  │  '0.35'  │  '0.87'   │   '1.45'   │  '0.50'   │   '2.94'   │  '2.82'   │   '7.71'   │
│     no createMock      │ '5093' │ '112415' │ '409280' │  '127866'   │ '103042' │ '37318' │  '486916K'  │  '0.30'  │  '0.86'   │   '1.37'   │  '0.50'   │   '2.89'   │  '2.98'   │   '7.75'   │
│ One interface per file │ '5098' │ '112431' │ '439436' │  '137914'   │ '103077' │ '32346' │  '508633K'  │  '0.33'  │  '0.84'   │   '1.52'   │  '0.50'   │   '3.12'   │  '3.53'   │   '8.67'   │
│    Interface reuse     │ '5098' │ '97431'  │ '444436' │  '132914'   │ '93078'  │ '27346' │  '499714K'  │  '0.32'  │  '0.95'   │   '1.47'   │  '0.55'   │   '3.20'   │  '3.67'   │   '8.90'   │
│ One Interface / Reuse  │ '5098' │ '104931' │ '441936' │  '135414'   │ '98078'  │ '29846' │  '504858K'  │  '0.30'  │  '0.90'   │   '1.49'   │  '0.50'   │   '3.29'   │  '3.71'   │   '8.99'   │
└────────────────────────┴────────┴──────────┴──────────┴─────────────┴──────────┴─────────┴─────────────┴──────────┴───────────┴────────────┴───────────┴────────────┴───────────┴────────────┘

I just went ahead and opened this here (cc: @uittorio) - it's easier than having a commit hash lying around in some random conversation.

martinjlowm avatar Jun 20 '20 11:06 martinjlowm

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 ?


interface B<T> {
    prop: T;
  }
  interface A extends B<A> {
    a: boolean;
    test: A;
  }

  const type: A = createMock<A>();
  expect(type.test.prop.a).toBe(false); // undefined

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

w: function () {
                             return new this.constructor;
}

uittorio avatar Jun 25 '20 07:06 uittorio

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 ?


interface B<T> {
    prop: T;
  }
  interface A extends B<A> {
    a: boolean;
    test: A;
  }

  const type: A = createMock<A>();
  expect(type.test.prop.a).toBe(false); // undefined

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

w: function () {
                             return new this.constructor;
}

I see!

So, the fix is easy, but I'd like some thoughts on some naming.

The problem lies here: https://github.com/Typescript-TDD/ts-auto-mock/blob/57727e6de0219146a621f0a9ee376088c8f9e211/src/transformer/genericDeclaration/genericDeclaration.ts#L160-L162

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 _C (c, for constructed and _ to keep it consistent with generated keys) and the constructor will always be emitted on the first occurrence.

Alternatively, we may also extend Scope with a "bind"-state and toggle it on here (to know that a constructor has been emitted, thus binding this). I don't know, maybe, it'll become useful in other scenarios.

        if (!typeParameterDeclaration || !scope.isBound()) {
          genericValueDescriptor = GetDescriptor(genericNode, (new Scope(declarationKey)).bind());
        }

What do You think?

martinjlowm avatar Jun 26 '20 16:06 martinjlowm

I've found another problem with a much simpler example. It seems like the this.constructor code doesn't work as expected, in your tests you're not hitting that code so they don't fail.

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: https://github.com/Typescript-TDD/ts-auto-mock/pull/389/commits/99cb2de0631740f710f9df502f41aefe78e5be3b#diff-a93f7cd70330ab19d1c4df5c1b3bd0e7R258

expect(propertiesA.a.b).toBe(0);

That hits only the code with Object.assign, to hit the return new this.constructor we need to go deeper and use again the a property:

expect(propertiesA.a.a.b).toBe(0);

That will fail because b is undefined. It seems like the last a is an empty object

Pmyl avatar Jun 26 '20 18:06 Pmyl

Right, that makes sense - I didn't realize class properties were emitted differently to interface properties. this is not bound correctly for class properties. I'll investigate :)

martinjlowm avatar Jun 26 '20 19:06 martinjlowm

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

Pmyl avatar Jun 26 '20 19:06 Pmyl

It appears there was a couple of issues:

  • The cached declaration key issue, I fixed by implementing the proposed Scope change. And actually, this led to the following issue.
  • Object.assign invokes the getter's when copying, leading to an inifinite recursion in runtime. This is fixed by using a combination of Object.defineProperties and Object.getOwnPropertyDescriptors. That also implies, that if you were to log the reference to stdout, the toString function will result in the same infinite recursion.
  • this is now temporarily stored as that from the MockIdentifierGenericCircularReference identifier to preserve the reference.

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 🤦

martinjlowm avatar Jun 27 '20 08:06 martinjlowm

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

Pmyl avatar Jun 29 '20 13:06 Pmyl

Sure, take your time :)

martinjlowm avatar Jun 29 '20 13:06 martinjlowm

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 @GenB_1T makes sense to be "new that.constructor" because that generic has to generate another instance of @A_1. The problem is in @GenC_1T, that shouldn't be A but GenB<A>. I don't think this can cause particular problems because A extends GenB<A> so for the rules of polymorphism those are actually matching!

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. If someone want to ignore this small issue please let me know, we can take a decision

Pmyl avatar Jul 08 '20 14:07 Pmyl

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 @GenC_1T is going to be A (new that.constructor) but it should be B. This breaks cases where no cast to any/unknown happened.

I'm not sure why this happens really, I think it's something about the scope... unfortunately this feature is more complex than expected

Pmyl avatar Jul 08 '20 15:07 Pmyl

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 :)

martinjlowm avatar Jul 09 '20 14:07 martinjlowm

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 that assignment and a reference to it, since reassignments may break previous references (through nesting). I updated the "bind"-scope change to bind for generic keys to avoid the conflicts when multiple extensions are present, i.e. a Set for bind() -> bindFor(key: string).

martinjlowm avatar Jul 20 '20 07:07 martinjlowm

454713fb2261d14c4dcb4fa5b06b87c7dcbb0117 solves the issue you discovered @Pmyl - I rewrote the extends test to cover those cases and a range of parallel circular extensions. MockIdentifierGenericCircularReference is replaced with a variable named as the uniqueName in createGenericParameter, with @ replaced by an _. We may have to name it differently to avoid variable clashing - perhaps by using MockPrivatePrefix instead? What do You guys think?

edit: That was perhaps a bit too quick I commented - discovered some cases that weren't covered.

martinjlowm avatar Jul 27 '20 18:07 martinjlowm

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 A_1_GenericC_1T relates to the GenericC<T> implementation of A.

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, GenericFG does not bind A and B properly. The bind functionality, does not consider more than one type argument. I hope the fix for that is easy :)

martinjlowm avatar Jul 31 '20 20:07 martinjlowm