From 2ec215e2124f6f2527aa626c78a914a32fe61dda Mon Sep 17 00:00:00 2001 From: Noel De Martin Date: Tue, 3 Oct 2023 11:29:53 +0200 Subject: [PATCH] MOBILE-4272 core: Improve asyncInstance inference --- src/core/features/native/services/native.ts | 4 +- src/core/utils/async-instance.ts | 73 ++++++++++------ src/core/utils/tests/async-instance.test.ts | 94 +++++++++++++++++++++ src/core/utils/types.ts | 5 ++ src/testing/utils.ts | 10 +++ 5 files changed, 160 insertions(+), 26 deletions(-) create mode 100644 src/core/utils/tests/async-instance.test.ts diff --git a/src/core/features/native/services/native.ts b/src/core/features/native/services/native.ts index 2f2bfc59b..60945027b 100644 --- a/src/core/features/native/services/native.ts +++ b/src/core/features/native/services/native.ts @@ -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> = {}; + private plugins: Partial>> = {}; private mocks: Partial> = {}; /** diff --git a/src/core/utils/async-instance.ts b/src/core/utils/async-instance.ts index 40352cb1c..187f64c19 100644 --- a/src/core/utils/async-instance.ts +++ b/src/core/utils/async-instance.ts @@ -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( - lazyConstructor?: () => TInstance | Promise, -): AsyncInstanceWrapper { - let promisedInstance: CorePromisedValue | null = null; +function createAsyncInstanceWrapper< + TLazyInstance extends TEagerInstance, + TEagerInstance extends AsyncObject = Partial +>( + lazyConstructor?: () => TLazyInstance | Promise, +): AsyncInstanceWrapper { + let promisedInstance: CorePromisedValue | null = null; let eagerInstance: TEagerInstance; return { @@ -90,20 +90,36 @@ function createAsyncInstanceWrapper unknown { + return typeof value === 'function'; +} + /** * Asynchronous instance wrapper. */ -export interface AsyncInstanceWrapper { - instance?: TInstance; +export interface AsyncInstanceWrapper< + TLazyInstance extends TEagerInstance, + TEagerInstance extends AsyncObject = Partial +> { + instance?: TLazyInstance; eagerInstance?: TEagerInstance; - getInstance(): Promise; - getProperty

(property: P): Promise; - setInstance(instance: TInstance): void; + getInstance(): Promise; + getProperty

(property: P): Promise; + setInstance(instance: TLazyInstance): void; setEagerInstance(eagerInstance: TEagerInstance): void; - setLazyConstructor(lazyConstructor: () => TInstance | Promise): void; + setLazyConstructor(lazyConstructor: () => TLazyInstance | Promise): 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 = * All methods are converted to their asynchronous version, and properties are available asynchronously using * the getProperty method. */ -export type AsyncInstance = - AsyncInstanceWrapper & TEagerInstance & { - [k in keyof TInstance]: AsyncMethod; +export type AsyncInstance> = + AsyncInstanceWrapper & { + [k in keyof TLazyInstance]: AsyncMethod; }; /** @@ -133,10 +149,10 @@ export type AsyncInstance( - lazyConstructor?: () => TInstance | Promise, -): AsyncInstance { - const wrapper = createAsyncInstanceWrapper(lazyConstructor); +export function asyncInstance>( + lazyConstructor?: () => TLazyInstance | Promise, +): AsyncInstance { + const wrapper = createAsyncInstanceWrapper(lazyConstructor); return new Proxy(wrapper, { get: (target, property, receiver) => { @@ -144,8 +160,12 @@ export function asyncInstance value.call(wrapper.instance, ...args) + : value; } if (wrapper.eagerInstance && property in wrapper.eagerInstance) { @@ -154,9 +174,14 @@ export function asyncInstance { 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; + }) as AsyncInstance; } diff --git a/src/core/utils/tests/async-instance.test.ts b/src/core/utils/tests/async-instance.test.ts new file mode 100644 index 000000000..c17f29b68 --- /dev/null +++ b/src/core/utils/tests/async-instance.test.ts @@ -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}; + + await expect(asyncService.thisDoesNotExist?.()).rejects.toBeInstanceOf(Error); + + expect(asyncService.thisDoesNotExist).toBeUndefined(); + }); + + it('restricts types hierarchy', () => { + type GetInstances = T extends AsyncInstance + ? { eager: TEagerInstance; lazy: TLazyInstance } + : never; + type GetEagerInstance = GetInstances['eager']; + type GetLazyInstance = GetInstances['lazy']; + + expectSameTypes>, LazyService>(true); + expectSameTypes>, Partial>(true); + + expectSameTypes>, LazyService>(true); + expectSameTypes>, EagerService>(true); + + // @ts-expect-error LazyService should extend FakeEagerService. + expectAnyType>(); + }); + + it('makes methods asynchronous', () => { + expectSameTypes['hello'], () => Promise>(true); + expectSameTypes['goodbye'], () => Promise>(true); + }); + +}); + +class EagerService { + + answer = 42; + +} + +class FakeEagerService { + + answer = '42'; + +} + +class LazyService extends EagerService { + + hello(): string { + return 'Hi there!'; + } + + async goodbye(): Promise { + return 'Sayonara!'; + } + +} diff --git a/src/core/utils/types.ts b/src/core/utils/types.ts index ff4aa5dba..d0131a35e 100644 --- a/src/core/utils/types.ts +++ b/src/core/utils/types.ts @@ -18,6 +18,11 @@ // eslint-disable-next-line @typescript-eslint/no-explicit-any export type Constructor = { new(...args: any[]): T }; +/** + * Helper type to infer whether two types are exactly the same. + */ +export type Equal = (() => T extends X ? 1 : 2) extends () => T extends Y ? 1 : 2 ? true : false; + /** * Helper type to flatten complex types. */ diff --git a/src/testing/utils.ts b/src/testing/utils.ts index 2a54ef674..092adaf13 100644 --- a/src/testing/utils.ts +++ b/src/testing/utils.ts @@ -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 { @@ -421,3 +422,12 @@ export function mockTranslate(translations: Record = {}): void { }, }); } + +export function expectSameTypes(equal: Equal): () => void { + return () => expect(equal).toBe(true); +} + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export function expectAnyType(): () => void { + return () => expect(true).toBe(true); +}