Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 1 addition & 5 deletions packages/core/__tests__/AsyncStorage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ describe('AsyncStorage', () => {
const mockedStorage = new StorageMock();

beforeEach(() => {
jest.resetAllMocks();
jest.clearAllMocks();
});

type testCases = [
Expand Down Expand Up @@ -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 () => {
Expand Down
88 changes: 87 additions & 1 deletion packages/core/__tests__/core.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -90,3 +91,88 @@ describe('SimpleErrorHandler', () => {
expect(console.error).toBeCalledWith('Fatal!');
});
});

describe('Extension', () => {
class StorageMock implements IStorageBackend<any> {
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<keyof IStorageBackend>([
'getSingle',
'setSingle',
'getMany',
'setMany',
'removeSingle',
'removeMany',
'getKeys',
'dropStorage',
])('does not contain Storage %s methods', methodName => {
const ext = createExtension<StorageMock>(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<StorageMock>(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<StorageMock>(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<any> {
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<Str>(instance);

const result = ext.extraWork();
// @ts-ignore
expect(instance._privateMethod).toBeCalled();
// @ts-ignore
expect(result).toEqual(instance.moduleNumber);
});
});
114 changes: 83 additions & 31 deletions packages/core/docs/Writing_Storage_Backend.md
Original file line number Diff line number Diff line change
@@ -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<MyModel> {
// 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

Expand Down Expand Up @@ -82,32 +163,3 @@ class WebStorage<T extends EmptyStorageModel = EmptyStorageModel> 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<K extends keyof T>(
key: K,
value: T[K],
opts?: StorageOptions,
): Promise<void> {

if(!opts.replaceCurrent) {
const current = this.storage.getItem(key);
if(!current){
this.storage.setItem(key, value);
}
return;
}

return this.storage.setItem(key, value);
}

```
12 changes: 6 additions & 6 deletions packages/core/src/AsyncStorage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,18 @@
*/

import {simpleErrorHandler, simpleLogger, noop} from './defaults';
import {createExtension} from './extension';
import {
ExtensionType,
FactoryOptions,
IStorageBackend,
LoggerAction,
StorageOptions,
} from '../types';

class AsyncStorage<M, T extends IStorageBackend<M>> {
readonly ext: ExtensionType<T>;

private readonly _backend: T;
private readonly _config: FactoryOptions;
private readonly log: (action: LoggerAction) => void;
Expand All @@ -28,6 +32,8 @@ class AsyncStorage<M, T extends IStorageBackend<M>> {
this.log = noop;
this.error = noop;

this.ext = createExtension<T>(this._backend);

if (this._config.logger) {
this.log =
typeof this._config.logger === 'function'
Expand Down Expand Up @@ -163,12 +169,6 @@ class AsyncStorage<M, T extends IStorageBackend<M>> {
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;
60 changes: 60 additions & 0 deletions packages/core/src/extension.ts
Original file line number Diff line number Diff line change
@@ -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<T extends IStorageBackend>(
storageInstance: T,
): ExtensionType<T> {
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<T>;
}
4 changes: 3 additions & 1 deletion packages/core/types/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ export class AsyncStorage<M, T extends IStorageBackend<M>> {

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

instance(): T;
ext: ExtensionType<T>;
}

/**
Expand Down Expand Up @@ -115,3 +115,5 @@ export type EmptyStorageModel = {[key in symbol | number | string]: any};
export type StorageOptions = {
[key: string]: any;
} | null;

export type ExtensionType<T> = Omit<T, keyof IStorageBackend>;
34 changes: 0 additions & 34 deletions packages/storage-legacy/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<MyModel>();
//
// 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',
// },
// },
// ]);
// }