MOBILE-4272 core: Improve asyncInstance inference

main
Noel De Martin 2023-10-03 11:29:53 +02:00
parent 056bb289b7
commit 2ec215e212
5 changed files with 160 additions and 26 deletions

View File

@ -15,7 +15,7 @@
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.
@ -23,7 +23,7 @@ import { AsyncInstance, asyncInstance } from '@/core/utils/async-instance';
@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]>> = {};
/**

View File

@ -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 {
@ -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.
*/
@ -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]>;
};
/**
@ -133,10 +149,10 @@ 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) => {
@ -144,8 +160,12 @@ export function asyncInstance<TEagerInstance extends AsyncObject, TInstance exte
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) {
@ -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);
return instance[property](...args);
if (!isMethod(method)) {
throw new Error(`'${property.toString()}' is not a function`);
}
return method.call(instance, ...args);
};
},
}) as AsyncInstance<TEagerInstance, TInstance>;
}) as AsyncInstance<TLazyInstance, TEagerInstance>;
}

View File

@ -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!';
}
}

View File

@ -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.
*/

View File

@ -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> {
@ -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);
}