From 7b2a7cf2e9581ee076cbca2c0e193a01d39891fc Mon Sep 17 00:00:00 2001 From: Joseff <40164689+joseffffff@users.noreply.github.com> Date: Sun, 23 Jun 2024 19:13:57 +0200 Subject: [PATCH] Cache Implementation (#14) --- README.md | 99 +++++++++++-- package-lock.json | 36 +++-- package.json | 3 +- src/GoogleSpreadsheetsOrm.ts | 96 ++++++++----- src/Options.ts | 27 +++- src/cache/CacheManager.ts | 67 +++++++++ src/cache/CacheProvider.ts | 5 + src/cache/InMemoryNodeCacheProvider.ts | 26 ++++ src/index.ts | 1 + src/serialization/NumberSerializer.ts | 6 +- src/utils/Plain.ts | 5 + tests/GoogleSpreadsheetsOrm.cache.test.ts | 161 ++++++++++++++++++++++ tests/GoogleSpreadsheetsOrm.test.ts | 3 +- tsconfig.json | 2 +- 14 files changed, 481 insertions(+), 56 deletions(-) create mode 100644 src/cache/CacheManager.ts create mode 100644 src/cache/CacheProvider.ts create mode 100644 src/cache/InMemoryNodeCacheProvider.ts create mode 100644 src/utils/Plain.ts create mode 100644 tests/GoogleSpreadsheetsOrm.cache.test.ts diff --git a/README.md b/README.md index cd6fc95..71947f4 100644 --- a/README.md +++ b/README.md @@ -4,20 +4,15 @@ Lightweight Node.js library simplifying Google Sheets integration, offering a ro object-relational mapping (ORM) interface, following the data-mapper pattern. This library enables seamless CRUD operations, including batch operations, ensuring a strict Typescript typing. -> [!WARNING] -> This library is still under construction, CRUD functionality will be available in a few weeks. - -## Quickstart - -### Install +## Install ```shell npm install google-spreadsheets-orm ``` -### Configuration +## Quickstart -Here's an example of an instantiation using `CustomerModel` interface as type for the `customers` sheet rows. +Here's a quick example using `CustomerModel` interface as type for the `customers` sheet rows. The example is using `GoogleAuth` from [google-auth-library](https://github.com/googleapis/google-auth-library-nodejs) as authentication, but any other auth option from the auth library is available to use, more info on the @@ -57,6 +52,10 @@ await orm.create({ }); ``` +More info about available methods in [Methods Overview](#methods-overview) section. + +## Configuration + ### Authentication Options GoogleSpreadsheetORM supports various authentication options for interacting with Google Sheets API. You can provide @@ -75,6 +74,90 @@ Alternatively, you can directly provide an array of `sheets_v4.Sheets` client in property. GoogleSpreadsheetORM distributes operations among the provided clients for load balancing. Quota retries for API rate limiting are automatically handled when using multiple clients. +### Cache + +Google Spreadsheets API can usually have high latencies, so using a Cache can be a good way to work around that issue. + +Enabling the cache is as simple as: + +```typescript +import { GoogleAuth } from 'google-auth-library'; +import { GoogleSpreadsheetOrm } from 'google-spreadsheets-orm'; + +interface CustomerModel { + id: string; + dateCreated: Date; + name: string; +} + +const orm = new GoogleSpreadsheetOrm({ + spreadsheetId: 'my-spreadsheet-id', + sheet: 'customers', + auth: new GoogleAuth({ + scopes: 'https://www.googleapis.com/auth/spreadsheets', + }), + castings: { + dateCreated: FieldType.DATE, + }, + cacheEnabled: true, // Enabling Cache ✅ + cacheTtlSeconds: 60, // Data will be cached for one minute ⏱️ +}); + +const firstCallResult = await orm.all(); // Data is fetched from spreadsheet and loaded into cache +const secondCallResult = await orm.all(); // Data is taken from cache 🏎️💨 +const thirdCallResult = await orm.all(); // 🏎️💨 +// more `all` calls... + +// Any write operation will invalidate the cache +orm.create({ + id: '1111-2222-3333-4444', + dateCreated: new Date(), + name: 'John Doe', +}); + +await orm.all(); // Data is fetched from spreadsheet again +``` + +### Cache Providers + +By default, an in-memory implementation is used. However, that might not be enough for some situations. In those cases +a custom implementation can be injected into the ORM, following the [`CacheProvider`](src/cache/CacheProvider.ts) +contract, example: + +```typescript +import { GoogleAuth } from 'google-auth-library'; +import { GoogleSpreadsheetOrm, CacheProvider } from 'google-spreadsheets-orm'; + +class RedisCacheProvider implements CacheProvider { + private dummyRedisClient; + + public async get(key: string): Promise { + return this.dummyRedisClient.get(key); + } + + public async set(key: string, value: T): Promise { + this.dummyRedisClient.set(key, value); + } + + public async invalidate(keys: string[]): Promise { + this.dummyRedisClient.del(keys); + } +} + +const orm = new GoogleSpreadsheetOrm({ + spreadsheetId: 'my-spreadsheet-id', + sheet: 'customers', + auth: new GoogleAuth({ + scopes: 'https://www.googleapis.com/auth/spreadsheets', + }), + castings: { + dateCreated: FieldType.DATE, + }, + cacheEnabled: true, // Enabling Cache ✅ + cacheProvider: new RedisCacheProvider(), // Using my custom provider 🤌 +}); +``` + ## Methods Overview GoogleSpreadsheetORM provides several methods for interacting with Google Sheets. Here's an overview of each method: diff --git a/package-lock.json b/package-lock.json index 6334b25..4ac5f9e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,8 @@ "license": "MIT", "dependencies": { "googleapis": "^134.0.0", - "luxon": "^3.4.4" + "luxon": "^3.4.4", + "node-cache": "^5.1.2" }, "devDependencies": { "@babel/core": "^7.24.4", @@ -3302,12 +3303,12 @@ } }, "node_modules/braces": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", - "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", "dev": true, "dependencies": { - "fill-range": "^7.0.1" + "fill-range": "^7.1.1" }, "engines": { "node": ">=8" @@ -3481,6 +3482,14 @@ "node": ">=12" } }, + "node_modules/clone": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz", + "integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==", + "engines": { + "node": ">=0.8" + } + }, "node_modules/co": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", @@ -4175,9 +4184,9 @@ } }, "node_modules/fill-range": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", - "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", "dev": true, "dependencies": { "to-regex-range": "^5.0.1" @@ -5727,6 +5736,17 @@ "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", "dev": true }, + "node_modules/node-cache": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/node-cache/-/node-cache-5.1.2.tgz", + "integrity": "sha512-t1QzWwnk4sjLWaQAS8CHgOJ+RAfmHpxFWmc36IWTiWHQfs0w5JDMBS1b1ZxQteo0vVVuWJvIUKHDkkeK7vIGCg==", + "dependencies": { + "clone": "2.x" + }, + "engines": { + "node": ">= 8.0.0" + } + }, "node_modules/node-fetch": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", diff --git a/package.json b/package.json index 4501e5a..86cecd4 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,8 @@ "license": "MIT", "dependencies": { "googleapis": "^134.0.0", - "luxon": "^3.4.4" + "luxon": "^3.4.4", + "node-cache": "^5.1.2" }, "devDependencies": { "@babel/core": "^7.24.4", diff --git a/src/GoogleSpreadsheetsOrm.ts b/src/GoogleSpreadsheetsOrm.ts index 1642c71..b4d0b79 100644 --- a/src/GoogleSpreadsheetsOrm.ts +++ b/src/GoogleSpreadsheetsOrm.ts @@ -15,15 +15,19 @@ import { BaseModel } from './BaseModel'; import { Metrics, MilliSecondsByOperation } from './metrics/Metrics'; import { MetricOperation } from './metrics/MetricOperation'; import Schema$ValueRange = sheets_v4.Schema$ValueRange; +import { CacheManager } from './cache/CacheManager'; +import { Plain } from './utils/Plain'; export class GoogleSpreadsheetsOrm { private readonly logger: Logger; private readonly sheetsClientProvider: GoogleSheetClientProvider; private readonly serializers: Map>; - private readonly instantiator: (rawRowObject: object) => T; + private readonly instantiator: (rawRowObject: Plain) => T; private readonly metricsCollector: Metrics; + private readonly cacheManager: CacheManager; + constructor(private readonly options: Options) { this.logger = new Logger(options.verbose); this.sheetsClientProvider = GoogleSheetClientProvider.fromOptions(options, this.logger); @@ -36,6 +40,8 @@ export class GoogleSpreadsheetsOrm { this.instantiator = options.instantiator ?? (r => r as T); this.metricsCollector = new Metrics(); + + this.cacheManager = new CacheManager(options); } /** @@ -153,6 +159,8 @@ export class GoogleSpreadsheetsOrm { }), ), ); + + await this.invalidateCaches(); } /** @@ -186,13 +194,15 @@ export class GoogleSpreadsheetsOrm { return; } - const { data } = await this.findSheetData(); - const rowNumbers = entityIds - .map(entityId => this.rowNumber(data, entityId)) - // rows are deleted from bottom to top - .sort((a, b) => b - a); - - const sheetId = await this.fetchSheetDetails().then(sheetDetails => sheetDetails.properties?.sheetId); + const [rowNumbers, sheetId] = await Promise.all([ + this.findSheetData().then(({ data }) => + entityIds + .map(entityId => this.rowNumber(data, entityId)) + // rows are deleted from bottom to top + .sort((a, b) => b - a), + ), + this.fetchSheetDetails().then(sheetDetails => sheetDetails.properties!.sheetId), + ]); await this.sheetsClientProvider.handleQuotaRetries(sheetsClient => this.metricsCollector.trackExecutionTime(MetricOperation.SHEET_DELETE, () => @@ -213,6 +223,8 @@ export class GoogleSpreadsheetsOrm { }), ), ); + + await this.invalidateCaches(); } /** @@ -258,6 +270,14 @@ export class GoogleSpreadsheetsOrm { }), ), ); + + await this.invalidateCaches(); + } + + private async invalidateCaches() { + if (this.options.cacheEnabled) { + await this.cacheManager.invalidate(); + } } /** @@ -281,13 +301,14 @@ export class GoogleSpreadsheetsOrm { } private async fetchSheetDetails(): Promise { - const sheets: GaxiosResponse = await this.sheetsClientProvider.handleQuotaRetries( - sheetsClient => + const sheets: GaxiosResponse = await this.cacheManager.getSheetDetailsOr(() => + this.sheetsClientProvider.handleQuotaRetries(sheetsClient => this.metricsCollector.trackExecutionTime(MetricOperation.FETCH_SHEET_DETAILS, () => sheetsClient.spreadsheets.get({ spreadsheetId: this.options.spreadsheetId, }), ), + ), ); const sheetDetails: sheets_v4.Schema$Sheet | undefined = sheets.data.sheets?.find( @@ -312,8 +333,8 @@ export class GoogleSpreadsheetsOrm { return index + 2; } - private toSheetArrayFromHeaders(entity: T, tableHeaders: string[]): ParsedSpreadsheetCellValue[] { - return tableHeaders.map(header => { + private toSheetArrayFromHeaders(entity: T, headers: string[]): ParsedSpreadsheetCellValue[] { + return headers.map(header => { const castingType: string | undefined = this.options?.castings?.[header as keyof T]; const entityValue = entity[header as keyof T] as ParsedSpreadsheetCellValue | undefined; @@ -355,46 +376,51 @@ export class GoogleSpreadsheetsOrm { } }); - return this.instantiator(entity); + return this.instantiator(entity as Plain); } private async findSheetData(): Promise<{ headers: string[]; data: string[][] }> { const data: string[][] = await this.allSheetData(); const headers: string[] = data.shift() as string[]; + await this.cacheManager.cacheHeaders(headers); return { headers, data }; } private async allSheetData(): Promise { - return this.sheetsClientProvider.handleQuotaRetries(async sheetsClient => - this.metricsCollector.trackExecutionTime(MetricOperation.FETCH_SHEET_DATA, async () => { - this.logger.log(`Querying all sheet data table=${this.options.sheet}`); - const db: GaxiosResponse = await sheetsClient.spreadsheets.values.get({ - spreadsheetId: this.options.spreadsheetId, - range: this.options.sheet, - }); - return db.data.values as string[][]; - }), + return this.cacheManager.getContentOr(() => + this.sheetsClientProvider.handleQuotaRetries(async sheetsClient => + this.metricsCollector.trackExecutionTime(MetricOperation.FETCH_SHEET_DATA, async () => { + this.logger.log(`Querying all sheet data sheet=${this.options.sheet}`); + const db: GaxiosResponse = await sheetsClient.spreadsheets.values.get({ + spreadsheetId: this.options.spreadsheetId, + range: this.options.sheet, + }); + return db.data.values as string[][]; + }), + ), ); } private async sheetHeaders(): Promise { - return this.sheetsClientProvider.handleQuotaRetries(async sheetsClient => - this.metricsCollector.trackExecutionTime(MetricOperation.FETCH_SHEET_HEADERS, async () => { - this.logger.log(`Reading headers from table=${this.options.sheet}`); + return this.cacheManager.getHeadersOr(() => + this.sheetsClientProvider.handleQuotaRetries(async sheetsClient => + this.metricsCollector.trackExecutionTime(MetricOperation.FETCH_SHEET_HEADERS, async () => { + this.logger.log(`Reading headers from sheet=${this.options.sheet}`); - const db: GaxiosResponse = await sheetsClient.spreadsheets.values.get({ - spreadsheetId: this.options.spreadsheetId, - range: `${this.options.sheet}!A1:1`, // users!A1:1 - }); + const db: GaxiosResponse = await sheetsClient.spreadsheets.values.get({ + spreadsheetId: this.options.spreadsheetId, + range: `${this.options.sheet}!A1:1`, // Example: users!A1:1 + }); - const values = db.data.values; + const values = db.data.values; - if (values && values.length > 0) { - return values[0] as string[]; - } + if (values && values.length > 0) { + return values[0] as string[]; + } - throw new GoogleSpreadsheetOrmError(`Headers row not present in sheet ${this.options.sheet}`); - }), + throw new GoogleSpreadsheetOrmError(`Headers row not present in sheet ${this.options.sheet}`); + }), + ), ); } diff --git a/src/Options.ts b/src/Options.ts index 083b443..119efbb 100644 --- a/src/Options.ts +++ b/src/Options.ts @@ -2,6 +2,8 @@ import { Castings } from './Castings'; import { BaseExternalAccountClient, GoogleAuth, OAuth2Client } from 'google-auth-library'; import { sheets_v4 } from 'googleapis'; import { BaseModel } from './BaseModel'; +import { CacheProvider } from './cache/CacheProvider'; +import type { Plain } from './utils/Plain'; export type AuthOptions = GoogleAuth | OAuth2Client | BaseExternalAccountClient | string; @@ -62,5 +64,28 @@ export interface Options { * * This function is useful for performing custom instantiation logic, especially with class-based objects. */ - readonly instantiator?: (values: object) => T; + readonly instantiator?: (values: Plain) => T; + + /** + * Flag to enable/disable cache, by default an in-memory implementation will be used. + * Any other implementation can be injected in {@link cacheProvider} property. + * + * @default false, (disabled). + */ + readonly cacheEnabled?: boolean; + + /** + * Number of seconds in which cache data will be used. Only used when using the default CacheProvider implementation. + * + * @default 30 seconds + */ + readonly cacheTtlSeconds?: number; + + /** + * Implementation for CacheProvider, will only be used if {@link `cacheEnabled`} is `true`. + * + * @default By default, an instance of {@link InMemoryNodeCacheProvider} will be used, but can be replaced + * by any other {@link CacheProvider} implementation. + */ + readonly cacheProvider?: CacheProvider; } diff --git a/src/cache/CacheManager.ts b/src/cache/CacheManager.ts new file mode 100644 index 0000000..26ed5f1 --- /dev/null +++ b/src/cache/CacheManager.ts @@ -0,0 +1,67 @@ +import { Options } from '../Options'; +import { BaseModel } from '../BaseModel'; +import { CacheProvider } from './CacheProvider'; +import { InMemoryNodeCacheProvider } from './InMemoryNodeCacheProvider'; +import { sheets_v4 } from 'googleapis'; +import { GaxiosResponse } from 'gaxios'; + +export class CacheManager { + private readonly cacheEnabled: boolean; + private readonly cacheProvider: CacheProvider; + + private readonly headersKey: string; + private readonly contentKey: string; + private readonly sheetDetailsKey: string; + + constructor(options: Options) { + this.headersKey = `headers-${options.sheet}`; + this.contentKey = `content-${options.sheet}`; + this.sheetDetailsKey = `details-${options.sheet}`; + this.cacheEnabled = !!options.cacheEnabled; + this.cacheProvider = options.cacheProvider ?? new InMemoryNodeCacheProvider(options.cacheTtlSeconds); + } + + public getHeadersOr(func: () => Promise): Promise { + return this.getOr(this.headersKey, func); + } + + public cacheHeaders(headers: string[]): Promise { + return this.cacheProvider.set(this.headersKey, headers); + } + + public getContentOr(func: () => Promise): Promise { + return this.getOr(this.contentKey, func); + } + + public async getSheetDetailsOr( + func: () => Promise>, + ): Promise> { + return this.getOr(this.sheetDetailsKey, func); + } + + public invalidate(): Promise { + return this.cacheProvider.invalidate([ + this.headersKey, + this.contentKey, + // not removing details key + ]); + } + + private async getOr(key: string, func: () => Promise): Promise { + if (!this.cacheEnabled) { + return func(); + } + + const cacheData = await this.cacheProvider.get(key); + + if (!!cacheData) { + return cacheData; + } + + const data = await func(); + + await this.cacheProvider.set(key, data); + + return data; + } +} diff --git a/src/cache/CacheProvider.ts b/src/cache/CacheProvider.ts new file mode 100644 index 0000000..386dc6e --- /dev/null +++ b/src/cache/CacheProvider.ts @@ -0,0 +1,5 @@ +export interface CacheProvider { + get(key: string): Promise; + set(key: string, value: T): Promise; + invalidate(keys: string[]): Promise; +} diff --git a/src/cache/InMemoryNodeCacheProvider.ts b/src/cache/InMemoryNodeCacheProvider.ts new file mode 100644 index 0000000..5c3844d --- /dev/null +++ b/src/cache/InMemoryNodeCacheProvider.ts @@ -0,0 +1,26 @@ +import { CacheProvider } from './CacheProvider'; +import NodeCache from 'node-cache'; + +const DEFAULT_CACHE_TTL_SECONDS: number = 30; + +export class InMemoryNodeCacheProvider implements CacheProvider { + private readonly innerCache: NodeCache; + + public constructor(ttlSeconds: number | undefined) { + this.innerCache = new NodeCache({ + stdTTL: ttlSeconds ?? DEFAULT_CACHE_TTL_SECONDS, + }); + } + + public async get(key: string): Promise { + return this.innerCache.get(key); + } + + public async set(key: string, value: T): Promise { + this.innerCache.set(key, value); + } + + public async invalidate(keys: string[]): Promise { + this.innerCache.del(keys); + } +} diff --git a/src/index.ts b/src/index.ts index dc42864..55eb6a4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,3 +3,4 @@ export * from './Options'; export * from './Castings'; export { BaseModel } from './BaseModel'; export { MetricOperation } from './metrics/MetricOperation'; +export { CacheProvider } from './cache/CacheProvider'; diff --git a/src/serialization/NumberSerializer.ts b/src/serialization/NumberSerializer.ts index c6a9363..05e3ae8 100644 --- a/src/serialization/NumberSerializer.ts +++ b/src/serialization/NumberSerializer.ts @@ -15,6 +15,10 @@ export class NumberSerializer implements Serializer { } public toSpreadsheetValue(value: number | undefined): string { - return value ? value.toString() : ''; + if (value === undefined) { + return ''; + } + + return value.toString(); } } diff --git a/src/utils/Plain.ts b/src/utils/Plain.ts new file mode 100644 index 0000000..e8ed8e8 --- /dev/null +++ b/src/utils/Plain.ts @@ -0,0 +1,5 @@ +import { BaseModel } from '../BaseModel'; + +export type Plain = { + [key in keyof T]: T[key] extends Function ? never : T[key]; +}; diff --git a/tests/GoogleSpreadsheetsOrm.cache.test.ts b/tests/GoogleSpreadsheetsOrm.cache.test.ts new file mode 100644 index 0000000..f4fbfab --- /dev/null +++ b/tests/GoogleSpreadsheetsOrm.cache.test.ts @@ -0,0 +1,161 @@ +import { mock, MockProxy } from 'jest-mock-extended'; +import { sheets_v4 } from 'googleapis'; +import { GoogleSpreadsheetsOrm } from '../src'; +import { FieldType } from '../src/serialization/FieldType'; +import Resource$Spreadsheets = sheets_v4.Resource$Spreadsheets; +import Resource$Spreadsheets$Values = sheets_v4.Resource$Spreadsheets$Values; +import Mock = jest.Mock; + +const SPREADSHEET_ID = 'spreadsheetId'; +const SHEET = 'test_entities'; + +class TestEntity { + constructor( + public readonly id: string, + public readonly name: string, + public readonly enabled: boolean, + ) {} +} + +describe('Cached ORM tests', () => { + let sheetClient: MockProxy; + let sut: GoogleSpreadsheetsOrm; + + beforeEach(() => { + sheetClient = mock(); + sheetClient.spreadsheets = mock(); + sheetClient.spreadsheets.values = mock(); + + sut = new GoogleSpreadsheetsOrm({ + spreadsheetId: SPREADSHEET_ID, + sheet: SHEET, + sheetClients: [sheetClient], + verbose: false, + cacheEnabled: true, // !! + cacheTtlSeconds: 1, + castings: { + enabled: FieldType.BOOLEAN, + }, + instantiator: row => new TestEntity(row.id, row.name, row.enabled), + }); + }); + + test('Should just call once to spreadsheets api if cache is enabled', async () => { + const rawValues = [ + ['id', 'name', 'enabled'], + ['ae222b54-182f-4958-b77f-26a3a04dff32', 'John Doe', 'false'], + ['ae222b54-182f-4958-b77f-26a3a04dff33', 'Donh Joe', 'true'], + ]; + + (sheetClient.spreadsheets.values as MockProxy).get.mockResolvedValue({ + data: { + values: rawValues, + }, + } as never); + + const firstResult = await sut.all(); + const secondResult = await sut.all(); + + expect(firstResult).toStrictEqual([ + new TestEntity('ae222b54-182f-4958-b77f-26a3a04dff32', 'John Doe', false), + new TestEntity('ae222b54-182f-4958-b77f-26a3a04dff33', 'Donh Joe', true), + ]); + expect(firstResult).toStrictEqual(secondResult); + expect(sheetClient.spreadsheets.values.get).toHaveBeenCalledTimes(1); // just one call + + await sleep(1000); // 1-second sleep + + await sut.all(); + expect(sheetClient.spreadsheets.values.get).toHaveBeenCalledTimes(2); // New call again after ttl consumed + }); + + test('delete should correctly use cache and invalidate after write process', async () => { + const rawValues = [ + ['id', 'name', 'enabled'], + ['ae222b54-182f-4958-b77f-26a3a04dff32', 'John Doe', 'false'], + ['ae222b54-182f-4958-b77f-26a3a04dff33', 'Donh Joe', 'true'], + ]; + + (sheetClient.spreadsheets.values as MockProxy).get.mockResolvedValue({ + data: { + values: rawValues, + }, + } as never); + (sheetClient.spreadsheets as MockProxy).get.mockResolvedValue({ + data: { + sheets: [ + { + properties: { + title: SHEET, + sheetId: 1234, + }, + }, + ], + }, + } as never); + + await sut.all(); // content cached here + + await sut.deleteById('ae222b54-182f-4958-b77f-26a3a04dff32'); + expect(sheetClient.spreadsheets.values.get).toHaveBeenCalledTimes(1); // create took headers from cache + expect(sheetClient.spreadsheets.get).toHaveBeenCalledTimes(1); // fetch sheet details + + await sut.deleteById('ae222b54-182f-4958-b77f-26a3a04dff33'); + expect(sheetClient.spreadsheets.values.get).toHaveBeenCalledTimes(2); // cache was invalidated in previous call + expect(sheetClient.spreadsheets.get).toHaveBeenCalledTimes(1); // Sheet details still in cache + }); + + test('create should correctly use cache and invalidate after write process', async () => { + // Configure table headers, so that save method can correctly match headers positions. + const rawValues = [['id', 'createdAt', 'name', 'jsonField', 'current', 'year']]; + + (sheetClient.spreadsheets.values as MockProxy).get.mockResolvedValue({ + data: { + values: rawValues, + }, + } as never); + + const entity = new TestEntity('ae222b54-182f-4958-b77f-26a3a04dff32', 'John Doe', false); + + await sut.all(); // headers cached here + + await sut.create(entity); + + expect(sheetClient.spreadsheets.values.get).toHaveBeenCalledTimes(1); // create took headers from cache + + await sut.all(); // Cache is invalidated on create, this should do another fetch + expect(sheetClient.spreadsheets.values.get).toHaveBeenCalledTimes(2); + }); + + test('update should correctly use cache and invalidate after write process', async () => { + const rawValues = [ + ['id', 'name', 'enabled'], + ['ae222b54-182f-4958-b77f-26a3a04dff32', 'John Doe', 'false'], + ['ae222b54-182f-4958-b77f-26a3a04dff33', 'Donh Joe', 'true'], + ]; + + (sheetClient.spreadsheets.values as MockProxy).get.mockResolvedValue({ + data: { + values: rawValues, + }, + } as never); + + const entity = new TestEntity( + 'ae222b54-182f-4958-b77f-26a3a04dff32', + 'John Doe 2', // updated + true, // updated + ); + + await sut.all(); // content cached here + + await sut.update(entity); + expect(sheetClient.spreadsheets.values.get).toHaveBeenCalledTimes(1); // took data from cache + + await sut.all(); // Cache is invalidated on create, this should do another fetch + expect(sheetClient.spreadsheets.values.get).toHaveBeenCalledTimes(2); + }); + + function sleep(millis: number): Promise { + return new Promise((resolve, reject) => setTimeout(resolve, millis)); + } +}); diff --git a/tests/GoogleSpreadsheetsOrm.test.ts b/tests/GoogleSpreadsheetsOrm.test.ts index adbf5af..0f78066 100644 --- a/tests/GoogleSpreadsheetsOrm.test.ts +++ b/tests/GoogleSpreadsheetsOrm.test.ts @@ -1,5 +1,5 @@ import { sheets_v4 } from 'googleapis'; -import { GoogleSpreadsheetsOrm } from '../src/GoogleSpreadsheetsOrm'; +import { GoogleSpreadsheetsOrm } from '../src'; import { FieldType } from '../src/serialization/FieldType'; import { mock, MockProxy } from 'jest-mock-extended'; import Resource$Spreadsheets$Values = sheets_v4.Resource$Spreadsheets$Values; @@ -41,6 +41,7 @@ describe(GoogleSpreadsheetsOrm.name, () => { sheet: SHEET, sheetClients, verbose: false, + cacheEnabled: false, castings: { createdAt: FieldType.DATE, jsonField: FieldType.JSON, diff --git a/tsconfig.json b/tsconfig.json index 44204ae..291a2e4 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -6,7 +6,7 @@ "allowJs": false, "skipLibCheck": true, "esModuleInterop": true, - "allowSyntheticDefaultImports": false, + "allowSyntheticDefaultImports": true, "strict": true, "forceConsistentCasingInFileNames": true, "noFallthroughCasesInSwitch": true,