MOBILE-3320 DX: Magic Singletons 🧙
parent
25a592e0b1
commit
2ab9d37f48
|
@ -56,23 +56,32 @@ import { Zip as ZipService } from '@ionic-native/zip/ngx';
|
||||||
|
|
||||||
import { TranslateService } from '@ngx-translate/core';
|
import { TranslateService } from '@ngx-translate/core';
|
||||||
|
|
||||||
|
const OBJECT_PROTOTYPE = Object.getPrototypeOf(Object);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Injector instance used to resolve singletons.
|
* Injector instance used to resolve singletons.
|
||||||
*/
|
*/
|
||||||
let singletonsInjector: Injector | null = null;
|
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 & {
|
export type CoreSingletonProxy<Service, Getters extends keyof Service = never> =
|
||||||
instance: Service;
|
Pick<Service, GetMethods<Service>> &
|
||||||
setInstance(instance: Service): void;
|
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.
|
* 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
|
* This method will return an object that will proxy method calls to an underlying service instance. Getters will also be proxied,
|
||||||
* provider was defined using a class or the string used in the `provide` key if it was defined using an object.
|
* 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> {
|
export function makeSingleton<Service>(injectionToken: Type<Service> | Type<unknown> | string): CoreSingletonProxy<Service, never>;
|
||||||
return class {
|
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;
|
Object.defineProperty(proxy, 'instance', {
|
||||||
|
get: () => {
|
||||||
static get instance(): Service {
|
if (!singletonsInjector) {
|
||||||
// Initialize instances lazily.
|
throw new Error('Can\'t resolve a singleton instance without an injector');
|
||||||
if (!this.serviceInstance) {
|
|
||||||
if (!singletonsInjector) {
|
|
||||||
throw new Error('Can\'t resolve a singleton instance without an injector');
|
|
||||||
}
|
|
||||||
|
|
||||||
this.serviceInstance = singletonsInjector.get(injectionToken);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
// Define getter proxies.
|
||||||
this.serviceInstance = instance;
|
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.
|
// Convert ionic-native services to singleton.
|
||||||
|
|
|
@ -38,7 +38,7 @@ export class CoreObject {
|
||||||
* @param keys Keys to remove from the new object.
|
* @param keys Keys to remove from the new object.
|
||||||
* @return New object without the specified keys.
|
* @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 };
|
const newObject: T = { ...obj };
|
||||||
|
|
||||||
for (const key of keys) {
|
for (const key of keys) {
|
||||||
|
|
|
@ -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);
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
|
@ -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 { Component, CUSTOM_ELEMENTS_SCHEMA, Type, ViewChild } from '@angular/core';
|
||||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
|
||||||
import { CoreSingletonClass } from '@singletons';
|
import { CoreSingletonProxy } from '@singletons';
|
||||||
|
|
||||||
abstract class WrapperComponent<U> {
|
abstract class WrapperComponent<U> {
|
||||||
|
|
||||||
|
@ -47,15 +47,15 @@ export function mock<T>(
|
||||||
return instance as T;
|
return instance as T;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function mockSingleton<T>(singletonClass: CoreSingletonClass<T>, instance: T): T;
|
export function mockSingleton<T>(singletonClass: CoreSingletonProxy<T>, instance: T): T;
|
||||||
export function mockSingleton<T>(singletonClass: CoreSingletonClass<unknown>, instance?: Record<string, unknown>): T;
|
export function mockSingleton<T>(singletonClass: CoreSingletonProxy<unknown>, instance?: Record<string, unknown>): T;
|
||||||
export function mockSingleton<T>(
|
export function mockSingleton<T>(
|
||||||
singletonClass: CoreSingletonClass<unknown>,
|
singletonClass: CoreSingletonProxy<unknown>,
|
||||||
methods: string[],
|
methods: string[],
|
||||||
instance?: Record<string, unknown>,
|
instance?: Record<string, unknown>,
|
||||||
): T;
|
): T;
|
||||||
export function mockSingleton<T>(
|
export function mockSingleton<T>(
|
||||||
singletonClass: CoreSingletonClass<T>,
|
singleton: CoreSingletonProxy<T>,
|
||||||
methodsOrInstance: string[] | Record<string, unknown> = [],
|
methodsOrInstance: string[] | Record<string, unknown> = [],
|
||||||
instance: Record<string, unknown> = {},
|
instance: Record<string, unknown> = {},
|
||||||
): T {
|
): T {
|
||||||
|
@ -64,7 +64,18 @@ export function mockSingleton<T>(
|
||||||
const methods = Array.isArray(methodsOrInstance) ? methodsOrInstance : [];
|
const methods = Array.isArray(methodsOrInstance) ? methodsOrInstance : [];
|
||||||
const mockInstance = mock<T>(methods, instance);
|
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;
|
return mockInstance;
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue