Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Cache Implementation #14

Merged
merged 12 commits into from
Jun 23, 2024
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
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
Loading