Skip to content
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

Open
wants to merge 15 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 6 commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions src/transformer/descriptor/helper/helper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,10 +60,10 @@ export namespace TypescriptHelper {
return declarations[0];
}

export function GetParameterOfNode(node: ts.EntityName): ts.NodeArray<ts.TypeParameterDeclaration> {
export function GetParameterOfNode(node: ts.EntityName): ts.NodeArray<ts.TypeParameterDeclaration> | undefined {
const declaration: ts.Declaration = GetDeclarationFromNode(node);

const { typeParameters = ts.createNodeArray([]) }: Declaration = (declaration as Declaration);
const { typeParameters }: Declaration = (declaration as Declaration);

return typeParameters;
}
Expand Down
110 changes: 86 additions & 24 deletions src/transformer/genericDeclaration/genericDeclaration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,22 +2,34 @@ import * as ts from 'typescript';
import { GetDescriptor } from '../descriptor/descriptor';
import { TypescriptHelper } from '../descriptor/helper/helper';
import { TypescriptCreator } from '../helper/creator';
import { TransformerLogger } from '../logger/transformerLogger';
import { MockDefiner } from '../mockDefiner/mockDefiner';
import { MockIdentifierGenericParameterIds, MockIdentifierGenericParameterValue } from '../mockIdentifier/mockIdentifier';
import { MockIdentifierGenericCircularReference, MockIdentifierGenericParameterIds, MockIdentifierGenericParameterValue } from '../mockIdentifier/mockIdentifier';
import { Scope } from '../scope/scope';
import { IGenericDeclaration } from './genericDeclaration.interface';
import { GenericDeclarationSupported } from './genericDeclarationSupported';
import { GenericParameter } from './genericParameter';

function isInstantiable(node: ts.Declaration | undefined): boolean {
let actualType: ts.Node | undefined = node;

if (!actualType) {
return false;
}

while (ts.isTypeAliasDeclaration(actualType)) {
actualType = actualType.type;
}

return !TypescriptHelper.IsLiteralOrPrimitive(actualType);
}

export function GenericDeclaration(scope: Scope): IGenericDeclaration {
const generics: GenericParameter[] = [];

function isGenericProvided<T extends ts.TypeReferenceNode | ts.ExpressionWithTypeArguments>(node: T, index: number): node is T & Required<ts.NodeWithTypeArguments> {
return !!node.typeArguments && !!node.typeArguments[index];
}

function getGenericNode(node: ts.TypeReferenceNode | ts.ExpressionWithTypeArguments, nodeDeclaration: ts.TypeParameterDeclaration, index: number): ts.Node {
function getGenericNode(node: ts.TypeReferenceNode | ts.ExpressionWithTypeArguments, nodeDeclaration: ts.TypeParameterDeclaration, index: number): ts.TypeNode {
if (isGenericProvided(node, index)) {
return node.typeArguments[index];
}
Expand All @@ -40,11 +52,60 @@ export function GenericDeclaration(scope: Scope): IGenericDeclaration {
}
}

function createGenericParameter(ownerKey: string, nodeOwnerParameter: ts.TypeParameterDeclaration, genericDescriptor: ts.Expression): GenericParameter {
function createGenericParameter(ownerKey: string, nodeOwnerParameter: ts.TypeParameterDeclaration, genericDescriptor: ts.Expression | undefined, instantiable: boolean): GenericParameter {
const uniqueName: string = ownerKey + nodeOwnerParameter.name.escapedText.toString();
const genericFunction: ts.FunctionExpression = TypescriptCreator.createFunctionExpression(ts.createBlock(
[ts.createReturn(genericDescriptor)],
));

const genericValueDescriptor: ts.Expression = ((): ts.Expression => {
if (!instantiable) {
return genericDescriptor || ts.createNull();
}

return ts.createNew(
genericDescriptor ? TypescriptCreator.createFunctionExpression(
ts.createBlock(
[
TypescriptCreator.createVariableStatement([
TypescriptCreator.createVariableDeclaration(MockIdentifierGenericCircularReference, ts.createIdentifier('this')),
]),
ts.createExpressionStatement(
ts.createCall(
ts.createPropertyAccess(
ts.createIdentifier('Object'),
ts.createIdentifier('defineProperties'),
),
undefined,
[
ts.createIdentifier('this'),
ts.createCall(
ts.createPropertyAccess(
ts.createIdentifier('Object'),
ts.createIdentifier('getOwnPropertyDescriptors'),
),
undefined,
[genericDescriptor]
),
]
),
),
],
),
) : ts.createPropertyAccess(
MockIdentifierGenericCircularReference,
ts.createIdentifier('constructor'),
),
undefined,
undefined,
);
})();

const genericFunction: ts.FunctionExpression =
TypescriptCreator.createFunctionExpression(
ts.createBlock([
ts.createReturn(
genericValueDescriptor,
),
]),
);

return {
ids: [uniqueName],
Expand All @@ -54,9 +115,9 @@ export function GenericDeclaration(scope: Scope): IGenericDeclaration {

return {
addFromTypeReferenceNode(node: ts.TypeReferenceNode, declarationKey: string): void {
const typeParameterDeclarations: ts.NodeArray<ts.TypeParameterDeclaration> = TypescriptHelper.GetParameterOfNode(node.typeName);
const typeParameterDeclarations: ts.NodeArray<ts.TypeParameterDeclaration> | undefined = TypescriptHelper.GetParameterOfNode(node.typeName);

if (!typeParameterDeclarations) {
if (!typeParameterDeclarations?.length) {
return;
}

Expand All @@ -66,7 +127,9 @@ export function GenericDeclaration(scope: Scope): IGenericDeclaration {
const genericParameter: GenericParameter = createGenericParameter(
declarationKey,
typeParameterDeclarations[index],
GetDescriptor(genericNode, scope));
GetDescriptor(genericNode, scope),
false,
);

generics.push(genericParameter);
});
Expand All @@ -79,24 +142,18 @@ export function GenericDeclaration(scope: Scope): IGenericDeclaration {
extension: ts.ExpressionWithTypeArguments): void {
const extensionDeclarationTypeParameters: ts.NodeArray<ts.TypeParameterDeclaration> | undefined = extensionDeclaration.typeParameters;

if (!extensionDeclarationTypeParameters) {
if (!extensionDeclarationTypeParameters?.length) {
return;
}

extensionDeclarationTypeParameters.reduce((acc: GenericParameter[], declaration: ts.TypeParameterDeclaration, index: number) => {
const genericNode: ts.Node = getGenericNode(extension, declaration, index);

let typeParameterDeclaration: ts.Declaration | undefined;
let genericValueDescriptor: ts.Expression | undefined;

if (ts.isTypeReferenceNode(genericNode)) {
const typeParameterDeclaration: ts.Declaration = TypescriptHelper.GetDeclarationFromNode(genericNode.typeName);

const isExtendingItself: boolean = MockDefiner.instance.getDeclarationKeyMap(typeParameterDeclaration) === declarationKey;
if (isExtendingItself) {
// FIXME: Currently, circular generics aren't supported. See
// https://github.com/Typescript-TDD/ts-auto-mock/pull/312 for more
// details.
TransformerLogger().circularGenericNotSupported(genericNode.getText());
return acc;
}
typeParameterDeclaration = TypescriptHelper.GetDeclarationFromNode(genericNode.typeName);

if (ts.isTypeParameterDeclaration(typeParameterDeclaration)) {
addGenericParameterToExisting(
Expand All @@ -110,10 +167,15 @@ export function GenericDeclaration(scope: Scope): IGenericDeclaration {
}
}

if (!typeParameterDeclaration || !scope.isBound()) {
Copy link
Member

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;

Copy link
Contributor Author

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.

genericValueDescriptor = GetDescriptor(genericNode, (new Scope(declarationKey)).bind());
}

const genericParameter: GenericParameter = createGenericParameter(
extensionDeclarationKey,
extensionDeclarationTypeParameters[index],
GetDescriptor(genericNode, scope),
declaration,
genericValueDescriptor,
isInstantiable(typeParameterDeclaration),
);

acc.push(genericParameter);
Expand Down
7 changes: 0 additions & 7 deletions src/transformer/logger/transformerLogger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import { ILogger } from '../../logger/logger.interface';
let logger: ILogger;

export interface TransformerLogger {
circularGenericNotSupported(nodeName: string): void;
unexpectedCreateMock(mockFileName: string, expectedFileName: string): void;
typeNotSupported(type: string): void;
typeOfFunctionCallNotFound(node: string): void;
Expand All @@ -15,12 +14,6 @@ export function TransformerLogger(): TransformerLogger {
logger = logger || Logger('Transformer');

return {
circularGenericNotSupported(nodeName: string): void {
logger.warning(
`Found a circular generic of \`${nodeName}' and such generics are currently not supported. ` +
'The generated mock will be incomplete.',
);
},
unexpectedCreateMock(mockFileName: string, expectedFileName: string): void {
logger.warning(`I\'ve found a mock creator but it comes from a different folder
found: ${mockFileName}
Expand Down
1 change: 1 addition & 0 deletions src/transformer/mockIdentifier/mockIdentifier.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import * as ts from 'typescript';

export const MockIdentifierGenericCircularReference: ts.Identifier = ts.createIdentifier('that');
export const MockIdentifierGenericParameter: ts.Identifier = ts.createIdentifier('t');
export const MockIdentifierGenericParameterIds: ts.Identifier = ts.createIdentifier('i');
export const MockIdentifierGenericParameterValue: ts.Identifier = ts.createIdentifier('w');
Expand Down
22 changes: 21 additions & 1 deletion src/transformer/scope/scope.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,32 @@ import * as ts from 'typescript';
export type InterfaceOrClassDeclaration = ts.InterfaceDeclaration | ts.ClassDeclaration;
export class Scope {
constructor(currentMockKey?: string) {
this._bound = false;
this._currentMockKey = currentMockKey;
}

private readonly _currentMockKey: string | undefined;
private _bound: boolean;

private _appendConstructorMarker(): string {
return this._bound ? '_C' : '';
}

public bind(): this {
this._bound = true;

return this;
}

public isBound(): boolean {
return this._bound;
}

public get currentMockKey(): string | undefined {
return this._currentMockKey;
if (this._currentMockKey === undefined) {
return;
}

return this._currentMockKey + this._appendConstructorMarker();
}
}
15 changes: 12 additions & 3 deletions test/transformer/descriptor/generic/extends.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -247,12 +247,21 @@ describe('for generic', () => {
describe('with circular', () => {
interface A extends ClassWithGenerics<A> {
b: number;
B: B;
}
interface B extends ClassWithGenerics<B> {
c: string;
A: A;
}

it('should avoid infinite extension', () => {
const properties: A = createMock<A>();
expect(properties.a).toBeDefined();
expect(properties.b).toBe(0);
const propertiesA: A = createMock<A>();
const propertiesB: B = createMock<B>();
expect(propertiesA.a.a.a.b).toBe(0);
expect(propertiesA.b).toBe(0);
expect(propertiesB.a.a.a.c).toBe('');
expect(propertiesB.c).toBe('');
expect(propertiesB.A.B.A.a.b).toBe(0);
});
});

Expand Down
28 changes: 0 additions & 28 deletions ui/src/views/types-not-supported.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -71,31 +71,6 @@ There is a branch created with a working version but it needs more investigation
[link](https://github.com/Typescript-TDD/ts-auto-mock/tree/feature/extends-mapped-type)


## Circular Generics

```ts
class C<T> {
public propC: T
public test: string
}

class A extends C<A> {
public propA: number
}
const a: A = createMock<A>();

// This will fail because we will not support generics of the same type.
expect(a.propC.propC.test).toBe("");
```

These are discussed here:
[link](https://github.com/Typescript-TDD/ts-auto-mock/pull/312). As of this
writing, the problem with circular generics is that the generated AST will
circle `A` over and over, and result in an infinite nested tree of declaration
references. The intended behavior is to have the first back-reference stored
elsewhere in the generated output and let it reference itself, making the
runtime a lazy-evaluated sequence of getters.

## Indexed access type with generics
```ts
interface StandardInterface {
Expand Down Expand Up @@ -128,6 +103,3 @@ interface StandardInterface {

type Hello = StandardInterface['prop'];
```