diff --git a/README.md b/README.md index 2540fb0..9b15ff9 100644 --- a/README.md +++ b/README.md @@ -172,3 +172,44 @@ await orm.delete(entitiesToDelete); - **Remarks**: - It internally fetches the sheet data to find which row needs to delete. - Quota retries are automatically handled to manage API rate limits. + +### `update(entity: T)` + +Updates the row in the specified sheet matching by id. All values are replaced with the ones in the entity param. + +```typescript +const myEntities: YourEntity[] = await orm.all(); + +const entityToUpdate: YourEntity = myEntities.find(e => e.id === '1111-2222-3333-4444'); +entityToUpdate.name = 'Updated name'; + +await orm.update(entityToUpdate); +``` + +- **Parameters**: + - `entity`: The entity object to update in the sheet. +- **Remarks**: + - It internally retrieves sheet data to ensure proper alignment of data and checking which row needs to update. + - Quota retries are automatically handled to manage API rate limits. + +### `updateAll(entities: T[])` + +Updates the rows in the specified sheet matching by id. All values are replaced with the ones in the entities param. + +```typescript +const myEntities: YourEntity[] = await orm.all(); + +const entitiesToUpdate: YourEntity[] = myEntities.filter(e => e.shouldBeDeleted()); + +entitiesToUpdate.forEach(entity => { + entity.name = 'Updated Name'; +}); + +await orm.updateAll(entitiesToUpdate); +``` + +- **Parameters**: + - `entities`: An array of entities objects to update in the sheet. +- **Remarks**: + - It internally retrieves sheet data to ensure proper alignment of data and checking which row needs to update. + - Quota retries are automatically handled to manage API rate limits. diff --git a/src/GoogleSpreadsheetsOrm.ts b/src/GoogleSpreadsheetsOrm.ts index 0350f2d..76915bd 100644 --- a/src/GoogleSpreadsheetsOrm.ts +++ b/src/GoogleSpreadsheetsOrm.ts @@ -43,7 +43,7 @@ export class GoogleSpreadsheetsOrm { * @returns A Promise that resolves to an array of entities of type T, representing all rows retrieved from the sheet. */ public async all(): Promise { - const { data, headers } = await this.findTableData(); + const { data, headers } = await this.findSheetData(); return this.rowsToEntities(data, headers); } @@ -78,6 +78,21 @@ export class GoogleSpreadsheetsOrm { return this.deleteAll([entity]); } + /** + * Updates the row in the specified sheet matching by id. All values are replaced with the ones in the entity param. + * + * @param entity - An entity object to update in the sheet. + * + * @remarks + * It retrieves sheet data to ensure proper alignment of data and checking which row needs to update. + * Quota retries are automatically handled to manage API rate limits. + * + * @returns A Promise that resolves when the row update process is completed successfully. + */ + public async update(entity: T): Promise { + return this.updateAll([entity]); + } + /** * Creates a new row in the specified sheet for each entity provided in the *entities* array. * @@ -124,7 +139,6 @@ export class GoogleSpreadsheetsOrm { * @param entities - An array of entities objects to delete * * @remarks - * @remarks * It internally retrieves all data from the specified sheet. * Quota retries are automatically handled to manage API rate limits. * @@ -135,7 +149,7 @@ export class GoogleSpreadsheetsOrm { return; } - const { data } = await this.findTableData(); + const { data } = await this.findSheetData(); const rowNumbers = entities .map(entity => this.rowNumber(data, entity)) // rows are deleted from bottom to top @@ -162,29 +176,49 @@ export class GoogleSpreadsheetsOrm { ); } - // public updateAll(entities: T[]): boolean { - // if (entities.length === 0) { - // return true; - // } - // - // if (entities.some(entity => entity.id === undefined)) { - // throw new InternalServerErrorException('Cannot update entities without id.'); - // } - // - // entities.forEach(entity => (entity.updatedAt = new Date())); - // - // const sheet = this.sheet(); - // - // const data = this.allSheetDataFromSheet(sheet); - // const headers = data.shift() as string[]; - // - // this.ioTimingsReporter.measureTime(InputOutputOperation.DB_UPDATE_ALL, () => - // entities.forEach(entity => this.replaceValues(this.rowNumber(data, entity), headers, sheet, entity)), - // ); - // - // return true; - // } - // + /** + * Updates the rows in the specified sheet matching by id. All values are replaced with the ones in the entities param. + * + * @param entities - An array of entities objects to update in the sheet. + * + * @remarks + * It retrieves sheet data to ensure proper alignment of data and checking which row needs to update. + * Quota retries are automatically handled to manage API rate limits. + * + * @returns A Promise that resolves when the row update process is completed successfully. + */ + public async updateAll(entities: T[]): Promise { + if (entities.length === 0) { + return; + } + + if (entities.some(entity => !entity.id)) { + throw new GoogleSpreadsheetOrmError('Cannot persist entities that have no id.'); + } + + const { headers, data } = await this.findSheetData(); + + await this.sheetsClientProvider.handleQuotaRetries(sheetsClient => + sheetsClient.spreadsheets.values.batchUpdate({ + spreadsheetId: this.options.spreadsheetId, + requestBody: { + valueInputOption: 'USER_ENTERED', + includeValuesInResponse: false, + data: entities.map(entity => { + const rowNumber = this.rowNumber(data, entity); + const range = this.buildRangeToUpdate(headers, rowNumber); + const entityAsSheetArray = this.toSheetArrayFromHeaders(entity, headers); + + return { + range, + values: [entityAsSheetArray], + }; + }), + }, + }), + ); + } + private async fetchSheetDetails(): Promise { const sheets = await this.sheetsClientProvider.handleQuotaRetries(sheetsClient => sheetsClient.spreadsheets.get({ @@ -260,17 +294,7 @@ export class GoogleSpreadsheetsOrm { return this.instantiator(entity); } - // private async update(entity: T): Promise { - // const { headers, data } = await this.findTableData(); - // - // const rowNumber = this.rowNumber(data, entity); - // - // await this.replaceValues(rowNumber, headers, entity); - // - // return true; - // } - - private async findTableData(): Promise<{ headers: string[]; data: string[][] }> { + private async findSheetData(): Promise<{ headers: string[]; data: string[][] }> { const data: string[][] = await this.allSheetData(); const headers: string[] = data.shift() as string[]; return { headers, data }; @@ -305,26 +329,11 @@ export class GoogleSpreadsheetsOrm { }); } - // private async replaceValues(rowNumber: number, headers: string[], entity: T): Promise { - // const values = this.toSheetArrayFromHeaders(entity, headers); - // - // // Transform header indexes into letters, to build the range. Example: 0 -> A, 1 -> B - // const columns = headers.map((_, index) => (index + 10).toString(36).toUpperCase()); - // const initialRange = `${columns[0]}${rowNumber}`; // Example A2 - // const endingRange = `${columns[columns.length - 1]}${rowNumber}`; // Example F2 - // const range = `${this.sheet}!${initialRange}:${endingRange}`; // Example users!A2:F2 - // - // this.logger.log(`Range: ${range}`); - // - // await this.sheetsClientProvider.handleQuotaRetries(sheetsClient => - // sheetsClient.spreadsheets.values.update({ - // spreadsheetId: this.spreadsheetId, - // range, - // valueInputOption: 'USER_ENTERED', - // requestBody: { - // values: [ values ], - // }, - // }), - // ); - // } + private buildRangeToUpdate(headers: string[], rowNumber: number): string { + // Transform header indexes into letters, to build the range. Example: 0 -> A, 1 -> B + const columns = headers.map((_, index) => (index + 10).toString(36).toUpperCase()); + const initialRange = `${columns[0]}${rowNumber}`; // Example A2 + const endingRange = `${columns[columns.length - 1]}${rowNumber}`; // Example F2 + return `${this.options.sheet}!${initialRange}:${endingRange}`; // Example users!A2:F2 + } } diff --git a/tests/GoogleSpreadsheetsOrm.test.ts b/tests/GoogleSpreadsheetsOrm.test.ts index 2f27c12..ad6f5d6 100644 --- a/tests/GoogleSpreadsheetsOrm.test.ts +++ b/tests/GoogleSpreadsheetsOrm.test.ts @@ -11,9 +11,6 @@ import { GoogleSpreadsheetOrmError } from '../src/errors/GoogleSpreadsheetOrmErr const SPREADSHEET_ID = 'spreadsheetId'; const SHEET = 'test_entities'; -const UUID_REGEX = /^[0-9A-F]{8}-[0-9A-F]{4}-[4][0-9A-F]{3}-[89AB][0-9A-F]{3}-[0-9A-F]{12}$/i; -const DATE_REGEX = - /^(0?[1-9]|[12][0-9]|3[01])\/(0?[1-9]|1[0-2])\/\d{4} (0?[0-9]|1[0-9]|2[0-3]):([0-5][0-9]):([0-5][0-9])$/; interface TestEntity { readonly id: string; @@ -458,7 +455,171 @@ describe(GoogleSpreadsheetsOrm.name, () => { }); test('update should correctly send a single request to batchUpdate endpoint', async () => { + mockValuesResponse([ + ['id', 'createdAt', 'name', 'jsonField', 'current', 'year'], + [ + 'ae222b54-182f-4958-b77f-26a3a04dff34', // id + '29/12/2023 17:47:04', // createdAt + 'John Doe', // name + // language=json + '{"a":"b","c":[1,2,3]}', // jsonField + 'true', // current + '2023', // year + ], + [ + 'ae222b54-182f-4958-b77f-26a3a04dff35', // id + '29/12/2023 17:47:04', // createdAt + 'John Doe', // name + // language=json + '{"a":"b","c":[1,2,3]}', // jsonField + 'true', // current + '2023', // year + ], + ]); + + const entity: TestEntity = { + id: 'ae222b54-182f-4958-b77f-26a3a04dff35', + createdAt: new Date('2023-12-29 17:47:04'), + name: 'John Doe - Update', // changed + jsonField: { + a: 'c', // changed + c: [1, 2, 3], + }, + current: false, // changed + year: 2023, + }; + + await sut.update(entity); + + expect(getValuesBatchUpdateUsedSheetClient()?.spreadsheets.values.batchUpdate).toHaveBeenCalledWith({ + spreadsheetId: SPREADSHEET_ID, + requestBody: { + valueInputOption: 'USER_ENTERED', + includeValuesInResponse: false, + data: [ + { + range: `${SHEET}!A3:F3`, + values: [ + [ + 'ae222b54-182f-4958-b77f-26a3a04dff35', // id + '29/12/2023 17:47:04', // createdAt + 'John Doe - Update', // name + // language=json + '{"a":"c","c":[1,2,3]}', // jsonField + 'false', // current + '2023', // year + ], + ], + }, + ], + }, + }); + }); + + test('update should fail if entity has no id', async () => { + await expect( + // @ts-ignore + sut.update({ + /* no values */ + }), + ).rejects.toStrictEqual(new GoogleSpreadsheetOrmError('Cannot persist entities that have no id.')); + }); + + test('updateAll should do nothing if empty array is passed', async () => { + await sut.updateAll([]); + expect( + // @ts-ignore + sheetClients.every(client => client.spreadsheets.values.batchUpdate.mock.calls.length === 0), + ).toBeTruthy(); + }); + + test('updateAll should correctly update many rows', async () => { + mockValuesResponse([ + ['id', 'createdAt', 'name', 'jsonField', 'current', 'year'], + [ + 'ae222b54-182f-4958-b77f-26a3a04dff34', // id + '29/12/2023 17:47:04', // createdAt + 'John Doe', // name + // language=json + '{"a":"b","c":[1,2,3]}', // jsonField + 'true', // current + '2023', // year + ], + [ + 'ae222b54-182f-4958-b77f-26a3a04dff35', // id + '29/12/2023 17:47:04', // createdAt + 'John Doe', // name + // language=json + '{"a":"b","c":[1,2,3]}', // jsonField + 'true', // current + '2023', // year + ], + ]); + const entities: TestEntity[] = [ + { + id: 'ae222b54-182f-4958-b77f-26a3a04dff34', + createdAt: new Date('2023-12-29 17:47:04'), + name: 'John Doe - Update', // changed + jsonField: { + a: 'c', // changed + c: [1, 2, 3], + }, + current: false, // changed + year: 2025, // changed + }, + { + id: 'ae222b54-182f-4958-b77f-26a3a04dff35', + createdAt: new Date('2023-12-29 17:47:04'), + name: 'John Doe - Update', // changed + jsonField: { + a: 'c', // changed + c: [1, 2, 3], + }, + current: false, // changed + year: 2023, + }, + ]; + + await sut.updateAll(entities); + + expect(getValuesBatchUpdateUsedSheetClient()?.spreadsheets.values.batchUpdate).toHaveBeenCalledWith({ + spreadsheetId: SPREADSHEET_ID, + requestBody: { + valueInputOption: 'USER_ENTERED', + includeValuesInResponse: false, + data: [ + { + range: `${SHEET}!A2:F2`, + values: [ + [ + 'ae222b54-182f-4958-b77f-26a3a04dff34', // id + '29/12/2023 17:47:04', // createdAt + 'John Doe - Update', // name + // language=json + '{"a":"c","c":[1,2,3]}', // jsonField + 'false', // current + '2025', // year + ], + ], + }, + { + range: `${SHEET}!A3:F3`, + values: [ + [ + 'ae222b54-182f-4958-b77f-26a3a04dff35', // id + '29/12/2023 17:47:04', // createdAt + 'John Doe - Update', // name + // language=json + '{"a":"c","c":[1,2,3]}', // jsonField + 'false', // current + '2023', // year + ], + ], + }, + ], + }, + }); }); function mockValuesResponse(rawValues: string[][]): void { @@ -494,4 +655,12 @@ describe(GoogleSpreadsheetsOrm.name, () => { .find(client => client.spreadsheets.batchUpdate.mock.calls.length > 0) ); } + + function getValuesBatchUpdateUsedSheetClient(): sheets_v4.Sheets | undefined { + return ( + sheetClients + // @ts-ignore + .find(client => client.spreadsheets.values.batchUpdate.mock.calls.length > 0) + ); + } });