Skip to content

Commit

Permalink
MOBILE-4272 core: Improve asyncInstance inference
Browse files Browse the repository at this point in the history
  • Loading branch information
NoelDeMartin committed Oct 3, 2023
1 parent 056bb28 commit 2ec215e
Show file tree
Hide file tree
Showing 5 changed files with 160 additions and 26 deletions.
4 changes: 2 additions & 2 deletions src/core/features/native/services/native.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,15 +15,15 @@
import { Injectable } from '@angular/core';
import { makeSingleton } from '@singletons';
import { CorePlatform } from '@services/platform';
import { AsyncInstance, asyncInstance } from '@/core/utils/async-instance';
import { AsyncInstance, AsyncObject, asyncInstance } from '@/core/utils/async-instance';

/**
* Native plugin manager.
*/
@Injectable({ providedIn: 'root' })
export class CoreNativeService {

private plugins: Partial<Record<keyof MoodleAppPlugins, AsyncInstance>> = {};
private plugins: Partial<Record<keyof MoodleAppPlugins, AsyncInstance<AsyncObject>>> = {};
private mocks: Partial<Record<keyof MoodleAppPlugins, MoodleAppPlugins[keyof MoodleAppPlugins]>> = {};

/**
Expand Down
73 changes: 49 additions & 24 deletions src/core/utils/async-instance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,19 +14,19 @@

import { CorePromisedValue } from '@classes/promised-value';

// eslint-disable-next-line @typescript-eslint/ban-types
type AsyncObject = object;

/**
* Create a wrapper to hold an asynchronous instance.
*
* @param lazyConstructor Constructor to use the first time the instance is needed.
* @returns Asynchronous instance wrapper.
*/
function createAsyncInstanceWrapper<TEagerInstance extends AsyncObject, TInstance extends TEagerInstance>(
lazyConstructor?: () => TInstance | Promise<TInstance>,
): AsyncInstanceWrapper<TEagerInstance, TInstance> {
let promisedInstance: CorePromisedValue<TInstance> | null = null;
function createAsyncInstanceWrapper<
TLazyInstance extends TEagerInstance,
TEagerInstance extends AsyncObject = Partial<TLazyInstance>
>(
lazyConstructor?: () => TLazyInstance | Promise<TLazyInstance>,
): AsyncInstanceWrapper<TLazyInstance, TEagerInstance> {
let promisedInstance: CorePromisedValue<TLazyInstance> | null = null;
let eagerInstance: TEagerInstance;

return {
Expand Down Expand Up @@ -90,20 +90,36 @@ function createAsyncInstanceWrapper<TEagerInstance extends AsyncObject, TInstanc
};
}

/**
* Check whether the given value is a method.
*
* @param value Value.
* @returns Whether the given value is a method.
*/
function isMethod(value: unknown): value is (...args: unknown[]) => unknown {
return typeof value === 'function';
}

/**
* Asynchronous instance wrapper.
*/
export interface AsyncInstanceWrapper<TEagerInstance extends AsyncObject, TInstance extends TEagerInstance> {
instance?: TInstance;
export interface AsyncInstanceWrapper<
TLazyInstance extends TEagerInstance,
TEagerInstance extends AsyncObject = Partial<TLazyInstance>
> {
instance?: TLazyInstance;
eagerInstance?: TEagerInstance;
getInstance(): Promise<TInstance>;
getProperty<P extends keyof TInstance>(property: P): Promise<TInstance[P]>;
setInstance(instance: TInstance): void;
getInstance(): Promise<TLazyInstance>;
getProperty<P extends keyof TLazyInstance>(property: P): Promise<TLazyInstance[P]>;
setInstance(instance: TLazyInstance): void;
setEagerInstance(eagerInstance: TEagerInstance): void;
setLazyConstructor(lazyConstructor: () => TInstance | Promise<TInstance>): void;
setLazyConstructor(lazyConstructor: () => TLazyInstance | Promise<TLazyInstance>): void;
resetInstance(): void;
}

// eslint-disable-next-line @typescript-eslint/ban-types
export type AsyncObject = object;

/**
* Asynchronous version of a method.
*/
Expand All @@ -121,9 +137,9 @@ export type AsyncMethod<T> =
* All methods are converted to their asynchronous version, and properties are available asynchronously using
* the getProperty method.
*/
export type AsyncInstance<TEagerInstance extends AsyncObject = AsyncObject, TInstance extends TEagerInstance = TEagerInstance> =
AsyncInstanceWrapper<TEagerInstance, TInstance> & TEagerInstance & {
[k in keyof TInstance]: AsyncMethod<TInstance[k]>;
export type AsyncInstance<TLazyInstance extends TEagerInstance, TEagerInstance extends AsyncObject = Partial<TLazyInstance>> =
AsyncInstanceWrapper<TLazyInstance, TEagerInstance> & {
[k in keyof TLazyInstance]: AsyncMethod<TLazyInstance[k]>;
};

/**
Expand All @@ -133,19 +149,23 @@ export type AsyncInstance<TEagerInstance extends AsyncObject = AsyncObject, TIns
* @param lazyConstructor Constructor to use the first time the instance is needed.
* @returns Asynchronous instance.
*/
export function asyncInstance<TEagerInstance extends AsyncObject, TInstance extends TEagerInstance = TEagerInstance>(
lazyConstructor?: () => TInstance | Promise<TInstance>,
): AsyncInstance<TEagerInstance, TInstance> {
const wrapper = createAsyncInstanceWrapper<TEagerInstance, TInstance>(lazyConstructor);
export function asyncInstance<TLazyInstance extends TEagerInstance, TEagerInstance extends AsyncObject = Partial<TLazyInstance>>(
lazyConstructor?: () => TLazyInstance | Promise<TLazyInstance>,
): AsyncInstance<TLazyInstance, TEagerInstance> {
const wrapper = createAsyncInstanceWrapper<TLazyInstance, TEagerInstance>(lazyConstructor);

return new Proxy(wrapper, {
get: (target, property, receiver) => {
if (property in target) {
return Reflect.get(target, property, receiver);
}

if (wrapper.instance && property in wrapper.instance) {
return Reflect.get(wrapper.instance, property, receiver);
if (wrapper.instance) {
const value = Reflect.get(wrapper.instance, property, receiver);

return isMethod(value)
? async (...args: unknown[]) => value.call(wrapper.instance, ...args)
: value;
}

if (wrapper.eagerInstance && property in wrapper.eagerInstance) {
Expand All @@ -154,9 +174,14 @@ export function asyncInstance<TEagerInstance extends AsyncObject, TInstance exte

return async (...args: unknown[]) => {
const instance = await wrapper.getInstance();
const method = Reflect.get(instance, property, receiver);

if (!isMethod(method)) {
throw new Error(`'${property.toString()}' is not a function`);
}

return instance[property](...args);
return method.call(instance, ...args);
};
},
}) as AsyncInstance<TEagerInstance, TInstance>;
}) as AsyncInstance<TLazyInstance, TEagerInstance>;
}
94 changes: 94 additions & 0 deletions src/core/utils/tests/async-instance.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
// (C) Copyright 2015 Moodle Pty Ltd.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

import { AsyncInstance, asyncInstance } from '@/core/utils/async-instance';
import { expectAnyType, expectSameTypes } from '@/testing/utils';

describe('AsyncInstance', () => {

it('initializes instances lazily', async () => {
const asyncService = asyncInstance(() => new LazyService());

expect(asyncService.instance).toBe(undefined);
expect(await asyncService.hello()).toEqual('Hi there!');
expect(asyncService.instance).toBeInstanceOf(LazyService);
});

it('does not initialize instance for eager properties', async () => {
const asyncService = asyncInstance(() => new LazyService());

asyncService.setEagerInstance(new EagerService());

expect(asyncService.instance).toBeUndefined();
expect(asyncService.answer).toEqual(42);
expect(asyncService.instance).toBeUndefined();
expect(await asyncService.hello()).toEqual('Hi there!');
expect(asyncService.instance).toBeInstanceOf(LazyService);
});

it('preserves undefined properties after initialization', async () => {
const asyncService = asyncInstance(() => new LazyService()) as { thisDoesNotExist?: () => Promise<void>};

await expect(asyncService.thisDoesNotExist?.()).rejects.toBeInstanceOf(Error);

expect(asyncService.thisDoesNotExist).toBeUndefined();
});

it('restricts types hierarchy', () => {
type GetInstances<T> = T extends AsyncInstance<infer TLazyInstance, infer TEagerInstance>
? { eager: TEagerInstance; lazy: TLazyInstance }
: never;
type GetEagerInstance<T> = GetInstances<T>['eager'];
type GetLazyInstance<T> = GetInstances<T>['lazy'];

expectSameTypes<GetLazyInstance<AsyncInstance<LazyService>>, LazyService>(true);
expectSameTypes<GetEagerInstance<AsyncInstance<LazyService>>, Partial<LazyService>>(true);

expectSameTypes<GetLazyInstance<AsyncInstance<LazyService, EagerService>>, LazyService>(true);
expectSameTypes<GetEagerInstance<AsyncInstance<LazyService, EagerService>>, EagerService>(true);

// @ts-expect-error LazyService should extend FakeEagerService.
expectAnyType<AsyncInstance<LazyService, FakeEagerService>>();
});

it('makes methods asynchronous', () => {
expectSameTypes<AsyncInstance<LazyService>['hello'], () => Promise<string>>(true);
expectSameTypes<AsyncInstance<LazyService>['goodbye'], () => Promise<string>>(true);
});

});

class EagerService {

answer = 42;

}

class FakeEagerService {

answer = '42';

}

class LazyService extends EagerService {

hello(): string {
return 'Hi there!';
}

async goodbye(): Promise<string> {
return 'Sayonara!';
}

}
5 changes: 5 additions & 0 deletions src/core/utils/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,11 @@
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type Constructor<T> = { new(...args: any[]): T };

/**
* Helper type to infer whether two types are exactly the same.
*/
export type Equal<X, Y> = (<T>() => T extends X ? 1 : 2) extends <T>() => T extends Y ? 1 : 2 ? true : false;

/**
* Helper type to flatten complex types.
*/
Expand Down
10 changes: 10 additions & 0 deletions src/testing/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import { CoreIonLoadingElement } from '@classes/ion-loading';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
import { DefaultUrlSerializer, UrlSerializer } from '@angular/router';
import { CoreUtils, CoreUtilsProvider } from '@services/utils/utils';
import { Equal } from '@/core/utils/types';

abstract class WrapperComponent<U> {

Expand Down Expand Up @@ -421,3 +422,12 @@ export function mockTranslate(translations: Record<string, string> = {}): void {
},
});
}

export function expectSameTypes<A, B>(equal: Equal<A, B>): () => void {
return () => expect(equal).toBe(true);
}

// eslint-disable-next-line @typescript-eslint/no-unused-vars
export function expectAnyType<T>(): () => void {
return () => expect(true).toBe(true);
}

0 comments on commit 2ec215e

Please sign in to comment.