UNPKG

expo-sqlite

Version:

Provides access to a database using SQLite (https://www.sqlite.org/). The database is persisted across restarts of your app.

426 lines (369 loc) 12.8 kB
import AwaitLock from 'await-lock'; import { openDatabaseAsync, openDatabaseSync, type SQLiteDatabase } from './index'; /** * Update function for the [`setItemAsync()`](#setitemasynckey-value) or [`setItemSync()`](#setitemsynckey-value) method. It computes the new value based on the previous value. The function returns the new value to set for the key. * @param prevValue The previous value associated with the key, or `null` if the key was not set. * @returns The new value to set for the key. */ export type SQLiteStorageSetItemUpdateFunction = (prevValue: string | null) => string; const DATABASE_VERSION = 1; const STATEMENT_GET = 'SELECT value FROM storage WHERE key = ?;'; const STATEMENT_SET = 'INSERT INTO storage (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value;'; const STATEMENT_REMOVE = 'DELETE FROM storage WHERE key = ?;'; const STATEMENT_GET_ALL_KEYS = 'SELECT key FROM storage;'; const STATEMENT_CLEAR = 'DELETE FROM storage;'; const MIGRATION_STATEMENT_0 = 'CREATE TABLE IF NOT EXISTS storage (key TEXT PRIMARY KEY NOT NULL, value TEXT);'; /** * Key-value store backed by SQLite. This class accepts a `databaseName` parameter in its constructor, which is the name of the database file to use for the storage. */ export class SQLiteStorage { private db: SQLiteDatabase | null = null; private readonly awaitLock = new AwaitLock(); constructor(private readonly databaseName: string) {} //#region Asynchronous API /** * Retrieves the value associated with the given key asynchronously. */ async getItemAsync(key: string): Promise<string | null> { this.checkValidInput(key); const db = await this.getDbAsync(); const result = await db.getFirstAsync<{ value: string }>(STATEMENT_GET, key); return result?.value ?? null; } /** * Sets the value for the given key asynchronously. * If a function is provided, it computes the new value based on the previous value. */ async setItemAsync( key: string, value: string | SQLiteStorageSetItemUpdateFunction ): Promise<void> { this.checkValidInput(key, value); const db = await this.getDbAsync(); if (typeof value === 'function') { await db.withExclusiveTransactionAsync(async (tx) => { const prevResult = await tx.getFirstAsync<{ value: string }>(STATEMENT_GET, key); const prevValue = prevResult?.value ?? null; const nextValue = value(prevValue); this.checkValidInput(key, nextValue); await tx.runAsync(STATEMENT_SET, key, nextValue); }); return; } await db.runAsync(STATEMENT_SET, key, value); } /** * Removes the value associated with the given key asynchronously. */ async removeItemAsync(key: string): Promise<boolean> { this.checkValidInput(key); const db = await this.getDbAsync(); const result = await db.runAsync(STATEMENT_REMOVE, key); return result.changes > 0; } /** * Retrieves all keys stored in the storage asynchronously. */ async getAllKeysAsync(): Promise<string[]> { const db = await this.getDbAsync(); const result = await db.getAllAsync<{ key: string }>(STATEMENT_GET_ALL_KEYS); return result.map(({ key }) => key); } /** * Clears all key-value pairs from the storage asynchronously. */ async clearAsync(): Promise<boolean> { const db = await this.getDbAsync(); const result = await db.runAsync(STATEMENT_CLEAR); return result.changes > 0; } /** * Closes the database connection asynchronously. */ async closeAsync(): Promise<void> { await this.awaitLock.acquireAsync(); try { if (this.db) { await this.db.closeAsync(); this.db = null; } } finally { this.awaitLock.release(); } } //#endregion //#region Synchronous API /** * Retrieves the value associated with the given key synchronously. */ getItemSync(key: string): string | null { this.checkValidInput(key); const db = this.getDbSync(); const result = db.getFirstSync<{ value: string }>(STATEMENT_GET, key); return result?.value ?? null; } /** * Sets the value for the given key synchronously. * If a function is provided, it computes the new value based on the previous value. */ setItemSync(key: string, value: string | SQLiteStorageSetItemUpdateFunction): void { this.checkValidInput(key, value); const db = this.getDbSync(); if (typeof value === 'function') { db.withTransactionSync(() => { const prevResult = db.getFirstSync<{ value: string }>(STATEMENT_GET, key); const prevValue = prevResult?.value ?? null; const nextValue = value(prevValue); this.checkValidInput(key, nextValue); db.runSync(STATEMENT_SET, key, nextValue); }); return; } db.runSync(STATEMENT_SET, key, value); } /** * Removes the value associated with the given key synchronously. */ removeItemSync(key: string): boolean { this.checkValidInput(key); const db = this.getDbSync(); const result = db.runSync(STATEMENT_REMOVE, key); return result.changes > 0; } /** * Retrieves all keys stored in the storage synchronously. */ getAllKeysSync(): string[] { const db = this.getDbSync(); const result = db.getAllSync<{ key: string }>(STATEMENT_GET_ALL_KEYS); return result.map(({ key }) => key); } /** * Clears all key-value pairs from the storage synchronously. */ clearSync(): boolean { const db = this.getDbSync(); const result = db.runSync(STATEMENT_CLEAR); return result.changes > 0; } /** * Closes the database connection synchronously. */ closeSync(): void { if (this.db) { this.db.closeSync(); this.db = null; } } //#endregion //#region react-native-async-storage compatible API /** * Alias for [`getItemAsync()`](#getitemasynckey) method. */ async getItem(key: string): Promise<string | null> { return this.getItemAsync(key); } /** * Alias for [`setItemAsync()`](#setitemasynckey-value). */ async setItem(key: string, value: string | SQLiteStorageSetItemUpdateFunction): Promise<void> { await this.setItemAsync(key, value); } /** * Alias for [`removeItemAsync()`](#removeitemasynckey) method. */ async removeItem(key: string): Promise<void> { await this.removeItemAsync(key); } /** * Alias for [`getAllKeysAsync()`](#getallkeysasync) method. */ async getAllKeys(): Promise<string[]> { return this.getAllKeysAsync(); } /** * Alias for [`clearAsync()`](#clearasync) method. */ async clear(): Promise<void> { await this.clearAsync(); } /** * Merges the given value with the existing value for the given key asynchronously. * If the existing value is a JSON object, performs a deep merge. */ async mergeItem(key: string, value: string): Promise<void> { this.checkValidInput(key, value); await this.setItemAsync(key, (prevValue) => { if (prevValue == null) { return value; } const prevJSON = JSON.parse(prevValue); const newJSON = JSON.parse(value); const mergedJSON = SQLiteStorage.mergeDeep(prevJSON, newJSON); return JSON.stringify(mergedJSON); }); } /** * Retrieves the values associated with the given keys asynchronously. */ async multiGet(keys: string[]): Promise<[string, string | null][]> { return Promise.all( keys.map(async (key): Promise<[string, string | null]> => { this.checkValidInput(key); return [key, await this.getItemAsync(key)]; }) ); } /** * Sets multiple key-value pairs asynchronously. */ async multiSet(keyValuePairs: [string, string][]): Promise<void> { const db = await this.getDbAsync(); await db.withExclusiveTransactionAsync(async (tx) => { for (const [key, value] of keyValuePairs) { this.checkValidInput(key, value); await tx.runAsync(STATEMENT_SET, key, value); } }); } /** * Removes the values associated with the given keys asynchronously. */ async multiRemove(keys: string[]): Promise<void> { const db = await this.getDbAsync(); await db.withExclusiveTransactionAsync(async (tx) => { for (const key of keys) { this.checkValidInput(key); await tx.runAsync(STATEMENT_REMOVE, key); } }); } /** * Merges multiple key-value pairs asynchronously. * If existing values are JSON objects, performs a deep merge. */ async multiMerge(keyValuePairs: [string, string][]): Promise<void> { const db = await this.getDbAsync(); await db.withExclusiveTransactionAsync(async (tx) => { for (const [key, value] of keyValuePairs) { this.checkValidInput(key, value); const prevValue = await tx.getFirstAsync<{ value: string }>(STATEMENT_GET, key); if (prevValue == null) { await tx.runAsync(STATEMENT_SET, key, value); continue; } const prevJSON = JSON.parse(prevValue.value); const newJSON = JSON.parse(value); const mergedJSON = SQLiteStorage.mergeDeep(prevJSON, newJSON); await tx.runAsync(STATEMENT_SET, key, JSON.stringify(mergedJSON)); } }); } /** * Alias for [`closeAsync()`](#closeasync-1) method. */ async close(): Promise<void> { await this.closeAsync(); } //#endregion //#region Internals private async getDbAsync(): Promise<SQLiteDatabase> { await this.awaitLock.acquireAsync(); try { if (!this.db) { const db = await openDatabaseAsync(this.databaseName); await this.maybeMigrateDbAsync(db); this.db = db; } } finally { this.awaitLock.release(); } return this.db; } private getDbSync(): SQLiteDatabase { if (!this.db) { const db = openDatabaseSync(this.databaseName); this.maybeMigrateDbSync(db); this.db = db; } return this.db; } private maybeMigrateDbAsync(db: SQLiteDatabase) { return db.withTransactionAsync(async () => { const result = await db.getFirstAsync<{ user_version: number }>('PRAGMA user_version'); let currentDbVersion = result?.user_version ?? 0; if (currentDbVersion >= DATABASE_VERSION) { return; } if (currentDbVersion === 0) { await db.execAsync(MIGRATION_STATEMENT_0); currentDbVersion = 1; } await db.execAsync(`PRAGMA user_version = ${DATABASE_VERSION}`); }); } private maybeMigrateDbSync(db: SQLiteDatabase) { db.withTransactionSync(() => { const result = db.getFirstSync<{ user_version: number }>('PRAGMA user_version'); let currentDbVersion = result?.user_version ?? 0; if (currentDbVersion >= DATABASE_VERSION) { return; } if (currentDbVersion === 0) { db.execSync(MIGRATION_STATEMENT_0); currentDbVersion = 1; } db.execSync(`PRAGMA user_version = ${DATABASE_VERSION}`); }); } /** * Recursively merge two JSON objects. */ private static mergeDeep(target: any, source: any): any { if (typeof target !== 'object' || target === null) { return source; } if (typeof source !== 'object' || source === null) { return target; } const output = { ...target }; for (const key of Object.keys(source)) { if (source[key] instanceof Array) { if (!output[key]) { output[key] = []; } output[key] = output[key].concat(source[key]); } else if (typeof source[key] === 'object') { output[key] = this.mergeDeep(target[key], source[key]); } else { output[key] = source[key]; } } return output; } private checkValidInput(...input: unknown[]) { const [key, value] = input; if (typeof key !== 'string') { throw new Error( `[SQLiteStorage] Using ${typeof key} type for key is not supported. Use string instead. Key passed: ${key}` ); } if (input.length > 1 && typeof value !== 'string' && typeof value !== 'function') { throw new Error( `[SQLiteStorage] Using ${typeof value} type for value is not supported. Use string instead. Key passed: ${key}. Value passed : ${value}` ); } } //#endregion } /** * This default instance of the [`SQLiteStorage`](#sqlitestorage-1) class is used as a drop-in replacement for the `AsyncStorage` module from [`@react-native-async-storage/async-storage`](https://github.com/react-native-async-storage/async-storage). */ export const AsyncStorage = new SQLiteStorage('ExpoSQLiteStorage'); export default AsyncStorage; /** * Alias for [`AsyncStorage`](#sqliteasyncstorage), given the storage not only offers asynchronous methods. */ export const Storage = AsyncStorage;