diff --git a/src/core/singletons/index.ts b/src/core/singletons/index.ts index 616ff6296..103fd9aad 100644 --- a/src/core/singletons/index.ts +++ b/src/core/singletons/index.ts @@ -56,23 +56,32 @@ import { Zip as ZipService } from '@ionic-native/zip/ngx'; import { TranslateService } from '@ngx-translate/core'; +const OBJECT_PROTOTYPE = Object.getPrototypeOf(Object); + /** * Injector instance used to resolve singletons. */ let singletonsInjector: Injector | null = null; /** - * Stub class used to type anonymous classes created in the makeSingleton method. + * Helper to get service class methods. */ -class CoreSingleton {} +type GetMethods = { + [K in keyof T]: T[K] extends (...args: unknown[]) => unknown ? K : never; +}[keyof T]; /** - * Singleton class created using the factory. + * Singleton proxy created using the factory method. + * + * @see makeSingleton */ -export type CoreSingletonClass = typeof CoreSingleton & { - instance: Service; - setInstance(instance: Service): void; -}; +export type CoreSingletonProxy = + Pick> & + Pick & + { + instance: Service; + setInstance(instance: Service): void; + }; /** * Set the injector that will be used to resolve instances in the singletons of this module. @@ -84,34 +93,101 @@ export function setSingletonsInjector(injector: Injector): void { } /** - * Make a singleton for the given injection token. + * Make a singleton proxy for the given injection token. * - * @param injectionToken Injection token used to resolve the singleton instance. This is usually the service class if the - * provider was defined using a class or the string used in the `provide` key if it was defined using an object. + * This method will return an object that will proxy method calls to an underlying service instance. Getters will also be proxied, + * but these need to be configured manually using the `getters` argument. Most of the time, this proxy can be used directly like + * you would use a service instance. If you need to get the real service instance, it can be accessed through the `instance` + * property and it can be set with the `setInstance` method. + * + * @param injectionToken Injection token used to resolve the service. This is usually the service class if the provider was + * defined using a class or the string used in the `provide` key if it was defined using an object. + * @param getters Getter names to proxy. + * @return Singleton proxy. */ -export function makeSingleton(injectionToken: Type | Type | string): CoreSingletonClass { - return class { +export function makeSingleton(injectionToken: Type | Type | string): CoreSingletonProxy; +export function makeSingleton( + injectionToken: Type | Type | string, + getters: Getters[], +): CoreSingletonProxy; +export function makeSingleton( + injectionToken: Type | Type | string, + getters: Getters[] = [], +): CoreSingletonProxy { + // Define instance manipulation affordances. + const proxy = { + setInstance(instance: Service) { + Object.defineProperty(proxy, 'instance', { + value: instance, + configurable: true, + }); + }, + } as CoreSingletonProxy; - private static serviceInstance: Service; - - static get instance(): Service { - // Initialize instances lazily. - if (!this.serviceInstance) { - if (!singletonsInjector) { - throw new Error('Can\'t resolve a singleton instance without an injector'); - } - - this.serviceInstance = singletonsInjector.get(injectionToken); + Object.defineProperty(proxy, 'instance', { + get: () => { + if (!singletonsInjector) { + throw new Error('Can\'t resolve a singleton instance without an injector'); } - return this.serviceInstance; + const instance = singletonsInjector.get(injectionToken); + + proxy.setInstance(instance); + + return instance; + }, + configurable: true, + }); + + // Define method and getter proxies. + if (isServiceClass(injectionToken)) { + // Get property descriptors, going all the way up the prototype chain (for services extending other classes). + let parentPrototype = injectionToken; + let descriptors: Record = {}; + + do { + descriptors = { + ...Object.getOwnPropertyDescriptors(parentPrototype.prototype), + ...descriptors, + }; + + parentPrototype = Object.getPrototypeOf(parentPrototype); + } while (parentPrototype !== OBJECT_PROTOTYPE); + + // Don't proxy constructor calls. + delete descriptors['constructor']; + + // Define method proxies. + for (const [property, descriptor] of Object.entries(descriptors)) { + // Skip getters and setters. + if (descriptor.get || descriptor.set) { + continue; + } + + // Define method proxy. + Object.defineProperty(proxy, property, { + value: (...args) => proxy.instance[property].call(proxy.instance, ...args), + configurable: true, + }); } - static setInstance(instance: Service): void { - this.serviceInstance = instance; + // Define getter proxies. + for (const getter of getters) { + Object.defineProperty(proxy, getter, { get: () => proxy.instance[getter] }); } + } - }; + return proxy; +} + +/** + * Type guard to check if an injection token is a service class. + * + * @param injectionToken Injection token. + * @return Whether the token is a class. + */ +function isServiceClass(injectionToken: Type | string): injectionToken is Type { + return typeof injectionToken !== 'string'; } // Convert ionic-native services to singleton. diff --git a/src/core/singletons/object.ts b/src/core/singletons/object.ts index 589c8b834..8f6bf1fa3 100644 --- a/src/core/singletons/object.ts +++ b/src/core/singletons/object.ts @@ -38,7 +38,7 @@ export class CoreObject { * @param keys Keys to remove from the new object. * @return New object without the specified keys. */ - static without(obj: T, keys: K[]): Omit { + static without(obj: T, keys: K[]): Omit { const newObject: T = { ...obj }; for (const key of keys) { diff --git a/src/core/singletons/tests/singletons.test.ts b/src/core/singletons/tests/singletons.test.ts new file mode 100644 index 000000000..412a4fe8d --- /dev/null +++ b/src/core/singletons/tests/singletons.test.ts @@ -0,0 +1,53 @@ +// (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 { mock } from '@/testing/utils'; +import { CoreSingletonProxy, makeSingleton, setSingletonsInjector } from '@singletons'; + +import { MilkyWayService } from './stubs'; + +describe('Singletons', () => { + + let MilkyWay: CoreSingletonProxy; + + beforeEach(() => { + setSingletonsInjector(mock({ get: serviceClass => new serviceClass() })); + + MilkyWay = makeSingleton(MilkyWayService, ['MEANING_OF_LIFE']); + }); + + it('works using the service instance', () => { + expect(MilkyWay.instance.getTheMeaningOfLife()).toBe(42); + }); + + it('works using magic methods', () => { + expect(MilkyWay.getTheMeaningOfLife()).toBe(42); + }); + + it('works using magic getters', () => { + expect(MilkyWay.MEANING_OF_LIFE).toBe(42); + }); + + it('magic getters use the same instance', () => { + expect(MilkyWay.addYears(1)).toBe(1); + expect(MilkyWay.instance.addYears(1)).toBe(2); + expect(MilkyWay.addYears(1)).toBe(3); + expect(MilkyWay.instance.addYears(2)).toBe(5); + }); + + it('magic methods respect inheritance', () => { + expect(MilkyWay.isGalaxy()).toBe(true); + }); + +}); diff --git a/src/core/singletons/tests/stubs.ts b/src/core/singletons/tests/stubs.ts new file mode 100644 index 000000000..7850cf6cd --- /dev/null +++ b/src/core/singletons/tests/stubs.ts @@ -0,0 +1,39 @@ +// (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. + +export class Galaxy { + + isGalaxy(): boolean { + return true; + } + +} + +export class MilkyWayService extends Galaxy { + + readonly MEANING_OF_LIFE = 42; + + private years = 0; + + getTheMeaningOfLife(): number { + return this.MEANING_OF_LIFE; + } + + addYears(years: number): number { + this.years += years; + + return this.years; + } + +} diff --git a/src/testing/utils.ts b/src/testing/utils.ts index ab2425723..d27ef231c 100644 --- a/src/testing/utils.ts +++ b/src/testing/utils.ts @@ -15,7 +15,7 @@ import { Component, CUSTOM_ELEMENTS_SCHEMA, Type, ViewChild } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { CoreSingletonClass } from '@singletons'; +import { CoreSingletonProxy } from '@singletons'; abstract class WrapperComponent { @@ -47,15 +47,15 @@ export function mock( return instance as T; } -export function mockSingleton(singletonClass: CoreSingletonClass, instance: T): T; -export function mockSingleton(singletonClass: CoreSingletonClass, instance?: Record): T; +export function mockSingleton(singletonClass: CoreSingletonProxy, instance: T): T; +export function mockSingleton(singletonClass: CoreSingletonProxy, instance?: Record): T; export function mockSingleton( - singletonClass: CoreSingletonClass, + singletonClass: CoreSingletonProxy, methods: string[], instance?: Record, ): T; export function mockSingleton( - singletonClass: CoreSingletonClass, + singleton: CoreSingletonProxy, methodsOrInstance: string[] | Record = [], instance: Record = {}, ): T { @@ -64,7 +64,18 @@ export function mockSingleton( const methods = Array.isArray(methodsOrInstance) ? methodsOrInstance : []; const mockInstance = mock(methods, instance); - singletonClass.setInstance(mockInstance); + singleton.setInstance(mockInstance); + + for (const [property, descriptor] of Object.entries(Object.getOwnPropertyDescriptors(singleton))) { + if (typeof descriptor.value !== 'function' || property === 'setInstance') { + continue; + } + + Object.defineProperty(singleton, property, { + value: jest.fn(descriptor.value), + configurable: true, + }); + } return mockInstance; }