diff --git a/packages/server/src/api/controllers/table/external.ts b/packages/server/src/api/controllers/table/external.ts index fbdb5c0b3a..c3356919c8 100644 --- a/packages/server/src/api/controllers/table/external.ts +++ b/packages/server/src/api/controllers/table/external.ts @@ -53,7 +53,6 @@ export async function save( builderSocket?.emitDatasourceUpdate(ctx, datasource) return table } catch (err: any) { - throw err if (err instanceof Error) { ctx.throw(400, err.message) } else { diff --git a/packages/server/src/integrations/googlesheets.ts b/packages/server/src/integrations/googlesheets.ts index d6d19e2fd9..960d01ab52 100644 --- a/packages/server/src/integrations/googlesheets.ts +++ b/packages/server/src/integrations/googlesheets.ts @@ -378,6 +378,8 @@ export class GoogleSheetsIntegration implements DatasourcePlus { return this.create({ sheet, row: json.body as Row }) case Operation.BULK_CREATE: return this.createBulk({ sheet, rows: json.body as Row[] }) + case Operation.BULK_UPSERT: + return this.createBulk({ sheet, rows: json.body as Row[] }) case Operation.READ: return this.read({ ...json, sheet }) case Operation.UPDATE: @@ -557,32 +559,15 @@ export class GoogleSheetsIntegration implements DatasourcePlus { } else { rows = await sheet.getRows() } - // this is a special case - need to handle the _id, it doesn't exist - // we cannot edit the returned structure from google, it does not have - // setter functions and is immutable, easier to update the filters - // to look for the _rowNumber property rather than rowNumber - if (query.filters?.equal) { - const idFilterKeys = Object.keys(query.filters.equal).filter(filter => - filter.includes(GOOGLE_SHEETS_PRIMARY_KEY) - ) - for (let idFilterKey of idFilterKeys) { - const id = query.filters.equal[idFilterKey] - delete query.filters.equal[idFilterKey] - query.filters.equal[`_${GOOGLE_SHEETS_PRIMARY_KEY}`] = id - } - } if (hasFilters && query.paginate) { rows = rows.slice(offset, offset + limit) } const headerValues = sheet.headerValues - let response = [] - for (let row of rows) { - response.push( - this.buildRowObject(headerValues, row.toObject(), row.rowNumber) - ) - } + let response = rows.map(row => + this.buildRowObject(headerValues, row.toObject(), row.rowNumber) + ) response = dataFilters.runQuery(response, query.filters || {}) if (query.sort) { diff --git a/packages/server/src/integrations/tests/googlesheets.spec.ts b/packages/server/src/integrations/tests/googlesheets.spec.ts index e31d3e4330..d3044d17eb 100644 --- a/packages/server/src/integrations/tests/googlesheets.spec.ts +++ b/packages/server/src/integrations/tests/googlesheets.spec.ts @@ -104,11 +104,73 @@ describe("Google Sheets Integration", () => { }) }) - it.only("should be able to add a new row", async () => { - await config.api.row.save(table._id!, { + it("should be able to add a new row", async () => { + const row = await config.api.row.save(table._id!, { name: "Test Contact", description: "original description", }) + + expect(row.name).toEqual("Test Contact") + expect(row.description).toEqual("original description") + + expect(mock.cell("A2")).toEqual("Test Contact") + expect(mock.cell("B2")).toEqual("original description") + + const row2 = await config.api.row.save(table._id!, { + name: "Test Contact 2", + description: "original description 2", + }) + + expect(row2.name).toEqual("Test Contact 2") + expect(row2.description).toEqual("original description 2") + + // Notable that adding a new row adds it at the top, not the bottom. Not + // entirely sure if this is the intended behaviour or an incorrect + // implementation of the GoogleSheetsMock. + expect(mock.cell("A2")).toEqual("Test Contact 2") + expect(mock.cell("B2")).toEqual("original description 2") + + expect(mock.cell("A3")).toEqual("Test Contact") + expect(mock.cell("B3")).toEqual("original description") + }) + + it("should be able to add multiple rows", async () => { + await config.api.row.bulkImport(table._id!, { + rows: [ + { + name: "Test Contact 1", + description: "original description 1", + }, + { + name: "Test Contact 2", + description: "original description 2", + }, + ], + }) + + expect(mock.cell("A2")).toEqual("Test Contact 1") + expect(mock.cell("B2")).toEqual("original description 1") + expect(mock.cell("A3")).toEqual("Test Contact 2") + expect(mock.cell("B3")).toEqual("original description 2") + }) + + it("should be able to update a row", async () => { + const row = await config.api.row.save(table._id!, { + name: "Test Contact", + description: "original description", + }) + + expect(mock.cell("A2")).toEqual("Test Contact") + expect(mock.cell("B2")).toEqual("original description") + + await config.api.row.save(table._id!, { + ...row, + name: "Test Contact Updated", + description: "original description updated", + }) + + expect(mock.cell("A2")).toEqual("Test Contact Updated") + expect(mock.cell("B2")).toEqual("original description updated") }) }) }) diff --git a/packages/server/src/integrations/tests/utils/googlesheets.ts b/packages/server/src/integrations/tests/utils/googlesheets.ts index a690a105fb..4851104c8b 100644 --- a/packages/server/src/integrations/tests/utils/googlesheets.ts +++ b/packages/server/src/integrations/tests/utils/googlesheets.ts @@ -175,6 +175,20 @@ interface AppendParams { responseDateTimeRenderOption?: DateTimeRenderOption } +// https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets.values/batchGet#query-parameters +interface BatchGetParams { + ranges: string[] + majorDimension?: Dimension + valueRenderOption?: ValueRenderOption + dateTimeRenderOption?: DateTimeRenderOption +} + +// https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets.values/batchGet#response-body +interface BatchGetResponse { + spreadsheetId: string + valueRanges: ValueRange[] +} + interface AppendRequest { range: string params: AppendParams @@ -268,39 +282,57 @@ export class GoogleSheetsMock { } private mockAPI() { - this.get(`/v4/spreadsheets/${this.config.spreadsheetId}/`, () => + const spreadsheetId = this.config.spreadsheetId + + this.get(`/v4/spreadsheets/${spreadsheetId}/`, () => this.handleGetSpreadsheet() ) // https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets.values/batchUpdate this.post( - `/v4/spreadsheets/${this.config.spreadsheetId}/:batchUpdate`, + `/v4/spreadsheets/${spreadsheetId}/:batchUpdate`, (_uri, request) => this.handleBatchUpdate(request as BatchUpdateRequest) ) // https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets.values/update this.put( - new RegExp(`/v4/spreadsheets/${this.config.spreadsheetId}/values/.*`), + new RegExp(`/v4/spreadsheets/${spreadsheetId}/values/.*`), (_uri, request) => this.handleValueUpdate(request as ValueRange) ) - // https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets.values/get + // https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets.values/batchGet this.get( - new RegExp(`/v4/spreadsheets/${this.config.spreadsheetId}/values/.*`), + new RegExp(`/v4/spreadsheets/${spreadsheetId}/values:batchGet.*`), uri => { - const range = uri.split("/").pop() - if (!range) { - throw new Error("No range provided") + const url = new URL(uri, "https://sheets.googleapis.com/") + const params: BatchGetParams = { + ranges: url.searchParams.getAll("ranges"), + majorDimension: + (url.searchParams.get("majorDimension") as Dimension) || "ROWS", + valueRenderOption: + (url.searchParams.get("valueRenderOption") as ValueRenderOption) || + undefined, + dateTimeRenderOption: + (url.searchParams.get( + "dateTimeRenderOption" + ) as DateTimeRenderOption) || undefined, } - return this.getValueRange(decodeURIComponent(range)) + return this.handleBatchGet(params as unknown as BatchGetParams) } ) + // https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets.values/get + this.get(new RegExp(`/v4/spreadsheets/${spreadsheetId}/values/.*`), uri => { + const range = uri.split("/").pop() + if (!range) { + throw new Error("No range provided") + } + return this.getValueRange(decodeURIComponent(range)) + }) + // https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets.values/append this.post( - new RegExp( - `/v4/spreadsheets/${this.config.spreadsheetId}/values/.*:append` - ), + new RegExp(`/v4/spreadsheets/${spreadsheetId}/values/.*:append`), (_uri, request) => { const url = new URL(_uri, "https://sheets.googleapis.com/") const params: Record = Object.fromEntries( @@ -373,6 +405,19 @@ export class GoogleSheetsMock { } } + private handleBatchGet(params: BatchGetParams): BatchGetResponse { + const { ranges, majorDimension } = params + + if (majorDimension && majorDimension !== "ROWS") { + throw new Error("Only row-major updates are supported") + } + + return { + spreadsheetId: this.spreadsheet.spreadsheetId, + valueRanges: ranges.map(range => this.getValueRange(range)), + } + } + private handleBatchUpdate( batchUpdateRequest: BatchUpdateRequest ): BatchUpdateResponse {