From c8d9ef9d5c2925b9d0a2977a14ccdf43ba445915 Mon Sep 17 00:00:00 2001 From: Krzysztof Borowy Date: Tue, 24 Sep 2019 07:47:58 +0200 Subject: [PATCH 1/2] feat: storage extension --- packages/core/__tests__/AsyncStorage.test.ts | 6 +- packages/core/__tests__/core.test.ts | 88 +++++++++++++++++++- packages/core/src/AsyncStorage.ts | 12 +-- packages/core/src/extension.ts | 60 +++++++++++++ packages/core/types/index.d.ts | 4 +- packages/storage-legacy/src/index.ts | 34 -------- 6 files changed, 157 insertions(+), 47 deletions(-) create mode 100644 packages/core/src/extension.ts diff --git a/packages/core/__tests__/AsyncStorage.test.ts b/packages/core/__tests__/AsyncStorage.test.ts index 642df7ae..4e8c6234 100644 --- a/packages/core/__tests__/AsyncStorage.test.ts +++ b/packages/core/__tests__/AsyncStorage.test.ts @@ -16,7 +16,7 @@ describe('AsyncStorage', () => { const mockedStorage = new StorageMock(); beforeEach(() => { - jest.resetAllMocks(); + jest.clearAllMocks(); }); type testCases = [ @@ -54,10 +54,6 @@ describe('AsyncStorage', () => { const keys = await asyncStorage.getKeys(); expect(keys).toEqual(['key1', 'key2']); }); - - it('handles instance api call', async () => { - expect(asyncStorage.instance()).toBe(mockedStorage); - }); }); describe('utils', () => { it('uses logger when provided', async () => { diff --git a/packages/core/__tests__/core.test.ts b/packages/core/__tests__/core.test.ts index a97943dc..d56b7751 100644 --- a/packages/core/__tests__/core.test.ts +++ b/packages/core/__tests__/core.test.ts @@ -1,6 +1,7 @@ import Factory from '../src'; import {simpleLogger, simpleErrorHandler} from '../src/defaults'; -import {LoggerAction} from '../types'; +import {createExtension} from '../src/extension'; +import {IStorageBackend, LoggerAction} from '../types'; describe('AsyncStorageFactory', () => { it('Throws when tried to instantiate', () => { @@ -90,3 +91,88 @@ describe('SimpleErrorHandler', () => { expect(console.error).toBeCalledWith('Fatal!'); }); }); + +describe('Extension', () => { + class StorageMock implements IStorageBackend { + getSingle = jest.fn(); + setSingle = jest.fn(); + getMany = jest.fn(); + setMany = jest.fn(); + removeSingle = jest.fn(); + removeMany = jest.fn(); + getKeys = jest.fn(); + dropStorage = jest.fn(); + + extraPublicMethod = jest.fn(); + + _privateMethod = jest.fn(); + + stringProperty = 'string'; + } + + const storageInst = new StorageMock(); + + it.each([ + 'getSingle', + 'setSingle', + 'getMany', + 'setMany', + 'removeSingle', + 'removeMany', + 'getKeys', + 'dropStorage', + ])('does not contain Storage %s methods', methodName => { + const ext = createExtension(storageInst); + // @ts-ignore API methods are excluded + expect(ext[methodName]).not.toBeDefined(); + }); + + it('does not contain private methods or no-function properties', () => { + const ext = createExtension(storageInst); + + expect(ext._privateMethod).not.toBeDefined(); + expect(ext.stringProperty).not.toBeDefined(); + }); + + it('contains extra methods from Storage', () => { + storageInst.extraPublicMethod.mockImplementationOnce(() => 'Hello World'); + + const ext = createExtension(storageInst); + + expect(ext.extraPublicMethod).toBeDefined(); + + const result = ext.extraPublicMethod('arg', 1); + + expect(storageInst.extraPublicMethod).toBeCalledWith('arg', 1); + expect(result).toEqual('Hello World'); + }); + + it('runs extended methods in Storage context', () => { + class Str implements IStorageBackend { + getSingle = jest.fn(); + setSingle = jest.fn(); + getMany = jest.fn(); + setMany = jest.fn(); + removeSingle = jest.fn(); + removeMany = jest.fn(); + getKeys = jest.fn(); + dropStorage = jest.fn(); + + private moduleNumber = Math.round(Math.random() * 100); + + private _privateMethod = jest.fn(() => this.moduleNumber); + + extraWork = jest.fn(() => this._privateMethod()); + } + + const instance = new Str(); + + const ext = createExtension(instance); + + const result = ext.extraWork(); + // @ts-ignore + expect(instance._privateMethod).toBeCalled(); + // @ts-ignore + expect(result).toEqual(instance.moduleNumber); + }); +}); diff --git a/packages/core/src/AsyncStorage.ts b/packages/core/src/AsyncStorage.ts index 1f37454c..2c130e9a 100644 --- a/packages/core/src/AsyncStorage.ts +++ b/packages/core/src/AsyncStorage.ts @@ -7,7 +7,9 @@ */ import {simpleErrorHandler, simpleLogger, noop} from './defaults'; +import {createExtension} from './extension'; import { + ExtensionType, FactoryOptions, IStorageBackend, LoggerAction, @@ -15,6 +17,8 @@ import { } from '../types'; class AsyncStorage> { + readonly ext: ExtensionType; + private readonly _backend: T; private readonly _config: FactoryOptions; private readonly log: (action: LoggerAction) => void; @@ -28,6 +32,8 @@ class AsyncStorage> { this.log = noop; this.error = noop; + this.ext = createExtension(this._backend); + if (this._config.logger) { this.log = typeof this._config.logger === 'function' @@ -163,12 +169,6 @@ class AsyncStorage> { this.error(e); } } - - // todo: think how we could provide additional functions through AS, without returning the instance - // some kind of extension-like functionality - instance(): T { - return this._backend; - } } export default AsyncStorage; diff --git a/packages/core/src/extension.ts b/packages/core/src/extension.ts new file mode 100644 index 00000000..01ca67bc --- /dev/null +++ b/packages/core/src/extension.ts @@ -0,0 +1,60 @@ +/** + * Copyright (c) React Native Community. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ +import {ExtensionType, IStorageBackend} from '../types'; + +// Methods available in storage API, to be excluded from the extension +const EXCLUDED_METHODS = [ + 'getSingle', + 'setSingle', + 'getMany', + 'setMany', + 'removeSingle', + 'removeMany', + 'getKeys', + 'dropStorage', +]; + +/* + * Extension is an object containing 'public', function-type properties of Storage instance + * To property be include in the extension, it has to meet three conditions: + * - has public accessor + * - has to be a function + * - cannot start with an underscore (convention considered private in JS) + * + * All methods in the extensions are called in Storage instance context + */ +export function createExtension( + storageInstance: T, +): ExtensionType { + const propertyNames = Object.getOwnPropertyNames(storageInstance).filter( + propName => { + return ( + EXCLUDED_METHODS.indexOf(propName) === -1 && + !propName.startsWith('_') && + // @ts-ignore this is a property on the instance + typeof storageInstance[propName] === 'function' + ); + }, + ); + + let extension = {}; + propertyNames.forEach(propName => { + const desc = { + enumerable: true, + get: function() { + // @ts-ignore this is a property on the instance + return storageInstance[propName].bind(storageInstance); + }, + }; + + Object.defineProperty(extension, propName, desc); + }); + + Object.seal(extension); + return extension as ExtensionType; +} diff --git a/packages/core/types/index.d.ts b/packages/core/types/index.d.ts index 6f466bf0..c2d186e2 100644 --- a/packages/core/types/index.d.ts +++ b/packages/core/types/index.d.ts @@ -48,7 +48,7 @@ export class AsyncStorage> { clearStorage(opts?: StorageOptions): Promise; - instance(): T; + ext: ExtensionType; } /** @@ -115,3 +115,5 @@ export type EmptyStorageModel = {[key in symbol | number | string]: any}; export type StorageOptions = { [key: string]: any; } | null; + +export type ExtensionType = Omit; diff --git a/packages/storage-legacy/src/index.ts b/packages/storage-legacy/src/index.ts index 6ca81aa8..006f1dd5 100644 --- a/packages/storage-legacy/src/index.ts +++ b/packages/storage-legacy/src/index.ts @@ -222,37 +222,3 @@ export default class LegacyAsyncStorage< }); } } - -// type MyModel = { -// user: { -// name: string; -// }; -// preferences: { -// hour: boolean | null; -// hair: string; -// }; -// isEnabled: boolean; -// }; - -// async function xxx() { -// const a = new LegacyAsyncStorage(); -// -// const x = await a.getSingle('preferences'); -// -// x.hour; -// -// const all = await a.getMany(['user', 'isEnabled']); -// -// all.user; -// -// await a.setMany([ -// {user: {name: 'Jerry'}}, -// {isEnabled: false}, -// { -// preferences: { -// hour: true, -// hair: 'streight', -// }, -// }, -// ]); -// } From 2f309f28528f38cc7d3767901ba2f074ef440f52 Mon Sep 17 00:00:00 2001 From: Krzysztof Borowy Date: Sun, 29 Sep 2019 16:37:42 +0200 Subject: [PATCH 2/2] docs: ext feature --- packages/core/docs/Writing_Storage_Backend.md | 114 +++++++++++++----- 1 file changed, 83 insertions(+), 31 deletions(-) diff --git a/packages/core/docs/Writing_Storage_Backend.md b/packages/core/docs/Writing_Storage_Backend.md index e17f74f4..7aaceb8c 100644 --- a/packages/core/docs/Writing_Storage_Backend.md +++ b/packages/core/docs/Writing_Storage_Backend.md @@ -1,7 +1,88 @@ # Authoring Storage Backend -To create custom storage, one must create a class that implements `IStorageBackend` interface. -This contract makes sure that Core knows how to use it. +Async Storage is a [facade](https://en.wikipedia.org/wiki/Facade_pattern) over the underlying Storage solution. +In order for the new storage to be compatible, it has to implement `IStorageBackend` and its methods. + + +## Table of Content + +1. [Creating a storage](#creating-storage-backend) +2. [Adding extra functionality](#going-being-default-api) +3. [Example](#example) + + + +## Creating Storage Backend + +To create storage compatible with Async Storage, one must create a class that implements `IStorageBackend`. It contains a handful of methods, +that simplifies access to the storage features. Those methods are: + +- `getSingle` - retrieves a single element, using provided `key`. +- `setSingle` - sets a `value` under provided `key` +- `removeSingle` - removes an entry for provided `key` +- `getMany` - returns an array of `values`, for a provided array of `keys` +- `setMany` - provided an array of `key-value` pairs, saves them to the storage +- `removeMany` - removes a bunch of values, for a provided array of `keys` +- `getKeys` - returns an array of `keys` that were used to store values +- `dropStorage` - purges the storage + + +Few points to keep in mind while developing new storage: + +- Every public method should be asynchronous (returns a promise) - even if access to the storage is not. This helps to keep API consistent. +- Each method accepts additional `opts` argument, which can be used to modify the call (i. e. decide if the underlying value should be overridden, if already exists) + + + +## Going being default API + +Unified API can be limiting - storages differ from each other and contain features that others do not. Async Storage comes with an extension property, that lets you extend its standard API. + +The `ext` property is a custom getter that exposes publicly available methods from your Storage. +Let's say that you have a feature that removes entries older than 30 days and you call it `purge`. + +#### Notes + +In order for a property to be exposed: + +- It has to be a function +- It has to have `public` property access (for type safety) +- Does not start with _underscore_ character - AsyncStorage consider those private + + +#### Example: + +Simply add a public method to expose it for Async Storage's extension. + +```typescript +import { IStorageBackend } from '@react-native-community/async-storage'; + + +class MyStorage implements IStorageBackend { + // overridden methods here + + public async purgeEntries() { + // implementation + } +} +``` + +Now your method is exposed through `ext` property: + + +```typescript + +import MyStorage from 'my-awesome-storage' +import ASFactory from '@react-native-community/async-storage' + +const storage = ASFactory.create(new MyStorage(), {}); + +// somewhere in the codebase +async function removeOldEntries() { + await storage.ext.purgeEntries(); + console.log('Done!'); +} +``` ## Example @@ -82,32 +163,3 @@ class WebStorage implements ISt export default WebStorage; ``` - -### Notes - -- Each function should be asynchronous - even if access to storage is not. -- In `localStorage`, remember that __keys__ and __values__ are always `string` - it's up to you if you're going to stringify it or accept stringified arguments. -- `opts` argument can be used to 'enhance' each call, for example, one could use it to decide if the stored value should be replaced: - -```typescript - -// in a class - -async setSingle( - key: K, - value: T[K], - opts?: StorageOptions, -): Promise { - - if(!opts.replaceCurrent) { - const current = this.storage.getItem(key); - if(!current){ - this.storage.setItem(key, value); - } - return; - } - - return this.storage.setItem(key, value); -} - -```