Skip to content

Commit

Permalink
Cache Implementation (#14)
Browse files Browse the repository at this point in the history
  • Loading branch information
joseffffff authored Jun 23, 2024
1 parent 7c76729 commit 7b2a7cf
Show file tree
Hide file tree
Showing 14 changed files with 481 additions and 56 deletions.
99 changes: 91 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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<CustomerModel>({
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<T>(key: string): Promise<T | undefined> {
return this.dummyRedisClient.get(key);
}

public async set<T>(key: string, value: T): Promise<void> {
this.dummyRedisClient.set(key, value);
}

public async invalidate(keys: string[]): Promise<void> {
this.dummyRedisClient.del(keys);
}
}

const orm = new GoogleSpreadsheetOrm<CustomerModel>({
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:
Expand Down
36 changes: 28 additions & 8 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
96 changes: 61 additions & 35 deletions src/GoogleSpreadsheetsOrm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T extends BaseModel> {
private readonly logger: Logger;
private readonly sheetsClientProvider: GoogleSheetClientProvider;
private readonly serializers: Map<string, Serializer<unknown>>;

private readonly instantiator: (rawRowObject: object) => T;
private readonly instantiator: (rawRowObject: Plain<T>) => T;
private readonly metricsCollector: Metrics;

private readonly cacheManager: CacheManager<T>;

constructor(private readonly options: Options<T>) {
this.logger = new Logger(options.verbose);
this.sheetsClientProvider = GoogleSheetClientProvider.fromOptions(options, this.logger);
Expand All @@ -36,6 +40,8 @@ export class GoogleSpreadsheetsOrm<T extends BaseModel> {

this.instantiator = options.instantiator ?? (r => r as T);
this.metricsCollector = new Metrics();

this.cacheManager = new CacheManager(options);
}

/**
Expand Down Expand Up @@ -153,6 +159,8 @@ export class GoogleSpreadsheetsOrm<T extends BaseModel> {
}),
),
);

await this.invalidateCaches();
}

/**
Expand Down Expand Up @@ -186,13 +194,15 @@ export class GoogleSpreadsheetsOrm<T extends BaseModel> {
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, () =>
Expand All @@ -213,6 +223,8 @@ export class GoogleSpreadsheetsOrm<T extends BaseModel> {
}),
),
);

await this.invalidateCaches();
}

/**
Expand Down Expand Up @@ -258,6 +270,14 @@ export class GoogleSpreadsheetsOrm<T extends BaseModel> {
}),
),
);

await this.invalidateCaches();
}

private async invalidateCaches() {
if (this.options.cacheEnabled) {
await this.cacheManager.invalidate();
}
}

/**
Expand All @@ -281,13 +301,14 @@ export class GoogleSpreadsheetsOrm<T extends BaseModel> {
}

private async fetchSheetDetails(): Promise<sheets_v4.Schema$Sheet> {
const sheets: GaxiosResponse<sheets_v4.Schema$Spreadsheet> = await this.sheetsClientProvider.handleQuotaRetries(
sheetsClient =>
const sheets: GaxiosResponse<sheets_v4.Schema$Spreadsheet> = 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(
Expand All @@ -312,8 +333,8 @@ export class GoogleSpreadsheetsOrm<T extends BaseModel> {
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;

Expand Down Expand Up @@ -355,46 +376,51 @@ export class GoogleSpreadsheetsOrm<T extends BaseModel> {
}
});

return this.instantiator(entity);
return this.instantiator(entity as Plain<T>);
}

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<string[][]> {
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<Schema$ValueRange> = 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<Schema$ValueRange> = await sheetsClient.spreadsheets.values.get({
spreadsheetId: this.options.spreadsheetId,
range: this.options.sheet,
});
return db.data.values as string[][];
}),
),
);
}

private async sheetHeaders(): Promise<string[]> {
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<Schema$ValueRange> = await sheetsClient.spreadsheets.values.get({
spreadsheetId: this.options.spreadsheetId,
range: `${this.options.sheet}!A1:1`, // users!A1:1
});
const db: GaxiosResponse<Schema$ValueRange> = 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}`);
}),
),
);
}

Expand Down
Loading

0 comments on commit 7b2a7cf

Please sign in to comment.