forked from CIT/Vmeda.Online
		
	MOBILE-3320 DX: Magic Singletons 🧙
This commit is contained in:
		
							parent
							
								
									25a592e0b1
								
							
						
					
					
						commit
						2ab9d37f48
					
				@ -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<T> = {
 | 
			
		||||
    [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<Service> = typeof CoreSingleton & {
 | 
			
		||||
    instance: Service;
 | 
			
		||||
    setInstance(instance: Service): void;
 | 
			
		||||
};
 | 
			
		||||
export type CoreSingletonProxy<Service, Getters extends keyof Service = never> =
 | 
			
		||||
    Pick<Service, GetMethods<Service>> &
 | 
			
		||||
    Pick<Service, Getters> &
 | 
			
		||||
    {
 | 
			
		||||
        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<Service>(injectionToken: Type<Service> | Type<unknown> | string): CoreSingletonClass<Service> {
 | 
			
		||||
    return class {
 | 
			
		||||
export function makeSingleton<Service>(injectionToken: Type<Service> | Type<unknown> | string): CoreSingletonProxy<Service, never>;
 | 
			
		||||
export function makeSingleton<Service, Getters extends keyof Service>(
 | 
			
		||||
    injectionToken: Type<Service> | Type<unknown> | string,
 | 
			
		||||
    getters: Getters[],
 | 
			
		||||
): CoreSingletonProxy<Service, Getters>;
 | 
			
		||||
export function makeSingleton<Service, Getters extends keyof Service>(
 | 
			
		||||
    injectionToken: Type<Service> | Type<unknown> | string,
 | 
			
		||||
    getters: Getters[] = [],
 | 
			
		||||
): CoreSingletonProxy<Service, Getters> {
 | 
			
		||||
    // Define instance manipulation affordances.
 | 
			
		||||
    const proxy = {
 | 
			
		||||
        setInstance(instance: Service) {
 | 
			
		||||
            Object.defineProperty(proxy, 'instance', {
 | 
			
		||||
                value: instance,
 | 
			
		||||
                configurable: true,
 | 
			
		||||
            });
 | 
			
		||||
        },
 | 
			
		||||
    } as CoreSingletonProxy<Service, Getters>;
 | 
			
		||||
 | 
			
		||||
        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<string, PropertyDescriptor> = {};
 | 
			
		||||
 | 
			
		||||
        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<unknown> | string): injectionToken is Type<unknown> {
 | 
			
		||||
    return typeof injectionToken !== 'string';
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Convert ionic-native services to singleton.
 | 
			
		||||
 | 
			
		||||
@ -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<T, K extends keyof T>(obj: T, keys: K[]): Omit<T, keyof { [k in K]: unknown }> {
 | 
			
		||||
    static without<T, K extends keyof T>(obj: T, keys: K[]): Omit<T, K> {
 | 
			
		||||
        const newObject: T = { ...obj };
 | 
			
		||||
 | 
			
		||||
        for (const key of keys) {
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										53
									
								
								src/core/singletons/tests/singletons.test.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										53
									
								
								src/core/singletons/tests/singletons.test.ts
									
									
									
									
									
										Normal file
									
								
							@ -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<MilkyWayService, 'MEANING_OF_LIFE'>;
 | 
			
		||||
 | 
			
		||||
    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);
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
});
 | 
			
		||||
							
								
								
									
										39
									
								
								src/core/singletons/tests/stubs.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										39
									
								
								src/core/singletons/tests/stubs.ts
									
									
									
									
									
										Normal file
									
								
							@ -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;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
@ -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<U> {
 | 
			
		||||
 | 
			
		||||
@ -47,15 +47,15 @@ export function mock<T>(
 | 
			
		||||
    return instance as T;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function mockSingleton<T>(singletonClass: CoreSingletonClass<T>, instance: T): T;
 | 
			
		||||
export function mockSingleton<T>(singletonClass: CoreSingletonClass<unknown>, instance?: Record<string, unknown>): T;
 | 
			
		||||
export function mockSingleton<T>(singletonClass: CoreSingletonProxy<T>, instance: T): T;
 | 
			
		||||
export function mockSingleton<T>(singletonClass: CoreSingletonProxy<unknown>, instance?: Record<string, unknown>): T;
 | 
			
		||||
export function mockSingleton<T>(
 | 
			
		||||
    singletonClass: CoreSingletonClass<unknown>,
 | 
			
		||||
    singletonClass: CoreSingletonProxy<unknown>,
 | 
			
		||||
    methods: string[],
 | 
			
		||||
    instance?: Record<string, unknown>,
 | 
			
		||||
): T;
 | 
			
		||||
export function mockSingleton<T>(
 | 
			
		||||
    singletonClass: CoreSingletonClass<T>,
 | 
			
		||||
    singleton: CoreSingletonProxy<T>,
 | 
			
		||||
    methodsOrInstance: string[] | Record<string, unknown> = [],
 | 
			
		||||
    instance: Record<string, unknown> = {},
 | 
			
		||||
): T {
 | 
			
		||||
@ -64,7 +64,18 @@ export function mockSingleton<T>(
 | 
			
		||||
    const methods = Array.isArray(methodsOrInstance) ? methodsOrInstance : [];
 | 
			
		||||
    const mockInstance = mock<T>(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;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user