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