Skip to content

Commit bdb11c6

Browse files
author
Krzysztof Borowy
authored
[v2] Storage extension (#214)
Storage extension feature and docs
1 parent 248ec89 commit bdb11c6

File tree

7 files changed

+240
-78
lines changed

7 files changed

+240
-78
lines changed

packages/core/__tests__/AsyncStorage.test.ts

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ describe('AsyncStorage', () => {
1616
const mockedStorage = new StorageMock();
1717

1818
beforeEach(() => {
19-
jest.resetAllMocks();
19+
jest.clearAllMocks();
2020
});
2121

2222
type testCases = [
@@ -54,10 +54,6 @@ describe('AsyncStorage', () => {
5454
const keys = await asyncStorage.getKeys();
5555
expect(keys).toEqual(['key1', 'key2']);
5656
});
57-
58-
it('handles instance api call', async () => {
59-
expect(asyncStorage.instance()).toBe(mockedStorage);
60-
});
6157
});
6258
describe('utils', () => {
6359
it('uses logger when provided', async () => {

packages/core/__tests__/core.test.ts

Lines changed: 87 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import Factory from '../src';
22
import {simpleLogger, simpleErrorHandler} from '../src/defaults';
3-
import {LoggerAction} from '../types';
3+
import {createExtension} from '../src/extension';
4+
import {IStorageBackend, LoggerAction} from '../types';
45

56
describe('AsyncStorageFactory', () => {
67
it('Throws when tried to instantiate', () => {
@@ -90,3 +91,88 @@ describe('SimpleErrorHandler', () => {
9091
expect(console.error).toBeCalledWith('Fatal!');
9192
});
9293
});
94+
95+
describe('Extension', () => {
96+
class StorageMock implements IStorageBackend<any> {
97+
getSingle = jest.fn();
98+
setSingle = jest.fn();
99+
getMany = jest.fn();
100+
setMany = jest.fn();
101+
removeSingle = jest.fn();
102+
removeMany = jest.fn();
103+
getKeys = jest.fn();
104+
dropStorage = jest.fn();
105+
106+
extraPublicMethod = jest.fn();
107+
108+
_privateMethod = jest.fn();
109+
110+
stringProperty = 'string';
111+
}
112+
113+
const storageInst = new StorageMock();
114+
115+
it.each<keyof IStorageBackend>([
116+
'getSingle',
117+
'setSingle',
118+
'getMany',
119+
'setMany',
120+
'removeSingle',
121+
'removeMany',
122+
'getKeys',
123+
'dropStorage',
124+
])('does not contain Storage %s methods', methodName => {
125+
const ext = createExtension<StorageMock>(storageInst);
126+
// @ts-ignore API methods are excluded
127+
expect(ext[methodName]).not.toBeDefined();
128+
});
129+
130+
it('does not contain private methods or no-function properties', () => {
131+
const ext = createExtension<StorageMock>(storageInst);
132+
133+
expect(ext._privateMethod).not.toBeDefined();
134+
expect(ext.stringProperty).not.toBeDefined();
135+
});
136+
137+
it('contains extra methods from Storage', () => {
138+
storageInst.extraPublicMethod.mockImplementationOnce(() => 'Hello World');
139+
140+
const ext = createExtension<StorageMock>(storageInst);
141+
142+
expect(ext.extraPublicMethod).toBeDefined();
143+
144+
const result = ext.extraPublicMethod('arg', 1);
145+
146+
expect(storageInst.extraPublicMethod).toBeCalledWith('arg', 1);
147+
expect(result).toEqual('Hello World');
148+
});
149+
150+
it('runs extended methods in Storage context', () => {
151+
class Str implements IStorageBackend<any> {
152+
getSingle = jest.fn();
153+
setSingle = jest.fn();
154+
getMany = jest.fn();
155+
setMany = jest.fn();
156+
removeSingle = jest.fn();
157+
removeMany = jest.fn();
158+
getKeys = jest.fn();
159+
dropStorage = jest.fn();
160+
161+
private moduleNumber = Math.round(Math.random() * 100);
162+
163+
private _privateMethod = jest.fn(() => this.moduleNumber);
164+
165+
extraWork = jest.fn(() => this._privateMethod());
166+
}
167+
168+
const instance = new Str();
169+
170+
const ext = createExtension<Str>(instance);
171+
172+
const result = ext.extraWork();
173+
// @ts-ignore
174+
expect(instance._privateMethod).toBeCalled();
175+
// @ts-ignore
176+
expect(result).toEqual(instance.moduleNumber);
177+
});
178+
});

packages/core/docs/Writing_Storage_Backend.md

Lines changed: 83 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,88 @@
11
# Authoring Storage Backend
22

3-
To create custom storage, one must create a class that implements `IStorageBackend` interface.
4-
This contract makes sure that Core knows how to use it.
3+
Async Storage is a [facade](https://en.wikipedia.org/wiki/Facade_pattern) over the underlying Storage solution.
4+
In order for the new storage to be compatible, it has to implement `IStorageBackend` and its methods.
5+
6+
7+
## Table of Content
8+
9+
1. [Creating a storage](#creating-storage-backend)
10+
2. [Adding extra functionality](#going-being-default-api)
11+
3. [Example](#example)
12+
13+
14+
15+
## Creating Storage Backend
16+
17+
To create storage compatible with Async Storage, one must create a class that implements `IStorageBackend`. It contains a handful of methods,
18+
that simplifies access to the storage features. Those methods are:
19+
20+
- `getSingle` - retrieves a single element, using provided `key`.
21+
- `setSingle` - sets a `value` under provided `key`
22+
- `removeSingle` - removes an entry for provided `key`
23+
- `getMany` - returns an array of `values`, for a provided array of `keys`
24+
- `setMany` - provided an array of `key-value` pairs, saves them to the storage
25+
- `removeMany` - removes a bunch of values, for a provided array of `keys`
26+
- `getKeys` - returns an array of `keys` that were used to store values
27+
- `dropStorage` - purges the storage
28+
29+
30+
Few points to keep in mind while developing new storage:
31+
32+
- Every public method should be asynchronous (returns a promise) - even if access to the storage is not. This helps to keep API consistent.
33+
- 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)
34+
35+
36+
37+
## Going being default API
38+
39+
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.
40+
41+
The `ext` property is a custom getter that exposes publicly available methods from your Storage.
42+
Let's say that you have a feature that removes entries older than 30 days and you call it `purge`.
43+
44+
#### Notes
45+
46+
In order for a property to be exposed:
47+
48+
- It has to be a function
49+
- It has to have `public` property access (for type safety)
50+
- Does not start with _underscore_ character - AsyncStorage consider those private
51+
52+
53+
#### Example:
54+
55+
Simply add a public method to expose it for Async Storage's extension.
56+
57+
```typescript
58+
import { IStorageBackend } from '@react-native-community/async-storage';
59+
60+
61+
class MyStorage implements IStorageBackend<MyModel> {
62+
// overridden methods here
63+
64+
public async purgeEntries() {
65+
// implementation
66+
}
67+
}
68+
```
69+
70+
Now your method is exposed through `ext` property:
71+
72+
73+
```typescript
74+
75+
import MyStorage from 'my-awesome-storage'
76+
import ASFactory from '@react-native-community/async-storage'
77+
78+
const storage = ASFactory.create(new MyStorage(), {});
79+
80+
// somewhere in the codebase
81+
async function removeOldEntries() {
82+
await storage.ext.purgeEntries();
83+
console.log('Done!');
84+
}
85+
```
586

687
## Example
788

@@ -82,32 +163,3 @@ class WebStorage<T extends EmptyStorageModel = EmptyStorageModel> implements ISt
82163

83164
export default WebStorage;
84165
```
85-
86-
### Notes
87-
88-
- Each function should be asynchronous - even if access to storage is not.
89-
- 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.
90-
- `opts` argument can be used to 'enhance' each call, for example, one could use it to decide if the stored value should be replaced:
91-
92-
```typescript
93-
94-
// in a class
95-
96-
async setSingle<K extends keyof T>(
97-
key: K,
98-
value: T[K],
99-
opts?: StorageOptions,
100-
): Promise<void> {
101-
102-
if(!opts.replaceCurrent) {
103-
const current = this.storage.getItem(key);
104-
if(!current){
105-
this.storage.setItem(key, value);
106-
}
107-
return;
108-
}
109-
110-
return this.storage.setItem(key, value);
111-
}
112-
113-
```

packages/core/src/AsyncStorage.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,18 @@
77
*/
88

99
import {simpleErrorHandler, simpleLogger, noop} from './defaults';
10+
import {createExtension} from './extension';
1011
import {
12+
ExtensionType,
1113
FactoryOptions,
1214
IStorageBackend,
1315
LoggerAction,
1416
StorageOptions,
1517
} from '../types';
1618

1719
class AsyncStorage<M, T extends IStorageBackend<M>> {
20+
readonly ext: ExtensionType<T>;
21+
1822
private readonly _backend: T;
1923
private readonly _config: FactoryOptions;
2024
private readonly log: (action: LoggerAction) => void;
@@ -28,6 +32,8 @@ class AsyncStorage<M, T extends IStorageBackend<M>> {
2832
this.log = noop;
2933
this.error = noop;
3034

35+
this.ext = createExtension<T>(this._backend);
36+
3137
if (this._config.logger) {
3238
this.log =
3339
typeof this._config.logger === 'function'
@@ -163,12 +169,6 @@ class AsyncStorage<M, T extends IStorageBackend<M>> {
163169
this.error(e);
164170
}
165171
}
166-
167-
// todo: think how we could provide additional functions through AS, without returning the instance
168-
// some kind of extension-like functionality
169-
instance(): T {
170-
return this._backend;
171-
}
172172
}
173173

174174
export default AsyncStorage;

packages/core/src/extension.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
/**
2+
* Copyright (c) React Native Community.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*
7+
*/
8+
import {ExtensionType, IStorageBackend} from '../types';
9+
10+
// Methods available in storage API, to be excluded from the extension
11+
const EXCLUDED_METHODS = [
12+
'getSingle',
13+
'setSingle',
14+
'getMany',
15+
'setMany',
16+
'removeSingle',
17+
'removeMany',
18+
'getKeys',
19+
'dropStorage',
20+
];
21+
22+
/*
23+
* Extension is an object containing 'public', function-type properties of Storage instance
24+
* To property be include in the extension, it has to meet three conditions:
25+
* - has public accessor
26+
* - has to be a function
27+
* - cannot start with an underscore (convention considered private in JS)
28+
*
29+
* All methods in the extensions are called in Storage instance context
30+
*/
31+
export function createExtension<T extends IStorageBackend>(
32+
storageInstance: T,
33+
): ExtensionType<T> {
34+
const propertyNames = Object.getOwnPropertyNames(storageInstance).filter(
35+
propName => {
36+
return (
37+
EXCLUDED_METHODS.indexOf(propName) === -1 &&
38+
!propName.startsWith('_') &&
39+
// @ts-ignore this is a property on the instance
40+
typeof storageInstance[propName] === 'function'
41+
);
42+
},
43+
);
44+
45+
let extension = {};
46+
propertyNames.forEach(propName => {
47+
const desc = {
48+
enumerable: true,
49+
get: function() {
50+
// @ts-ignore this is a property on the instance
51+
return storageInstance[propName].bind(storageInstance);
52+
},
53+
};
54+
55+
Object.defineProperty(extension, propName, desc);
56+
});
57+
58+
Object.seal(extension);
59+
return extension as ExtensionType<T>;
60+
}

packages/core/types/index.d.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ export class AsyncStorage<M, T extends IStorageBackend<M>> {
4848

4949
clearStorage(opts?: StorageOptions): Promise<void>;
5050

51-
instance(): T;
51+
ext: ExtensionType<T>;
5252
}
5353

5454
/**
@@ -115,3 +115,5 @@ export type EmptyStorageModel = {[key in symbol | number | string]: any};
115115
export type StorageOptions = {
116116
[key: string]: any;
117117
} | null;
118+
119+
export type ExtensionType<T> = Omit<T, keyof IStorageBackend>;

packages/storage-legacy/src/index.ts

Lines changed: 0 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -222,37 +222,3 @@ export default class LegacyAsyncStorage<
222222
});
223223
}
224224
}
225-
226-
// type MyModel = {
227-
// user: {
228-
// name: string;
229-
// };
230-
// preferences: {
231-
// hour: boolean | null;
232-
// hair: string;
233-
// };
234-
// isEnabled: boolean;
235-
// };
236-
237-
// async function xxx() {
238-
// const a = new LegacyAsyncStorage<MyModel>();
239-
//
240-
// const x = await a.getSingle('preferences');
241-
//
242-
// x.hour;
243-
//
244-
// const all = await a.getMany(['user', 'isEnabled']);
245-
//
246-
// all.user;
247-
//
248-
// await a.setMany([
249-
// {user: {name: 'Jerry'}},
250-
// {isEnabled: false},
251-
// {
252-
// preferences: {
253-
// hour: true,
254-
// hair: 'streight',
255-
// },
256-
// },
257-
// ]);
258-
// }

0 commit comments

Comments
 (0)