More progress toward a row save test passing.

This commit is contained in:
Sam Rose 2024-09-09 16:33:35 +01:00
parent 1c5b50773f
commit 1eb8c3409a
No known key found for this signature in database
3 changed files with 219 additions and 99 deletions

View File

@ -395,7 +395,12 @@ export class GoogleSheetsIntegration implements DatasourcePlus {
sheet,
})
case Operation.CREATE_TABLE:
return this.createTable(json?.table?.name)
if (json.table === undefined) {
throw new Error(
"attempted to create a table without specifying the table to create"
)
}
return this.createTable(json.table)
case Operation.UPDATE_TABLE:
return this.updateTable(json.table!)
case Operation.DELETE_TABLE:
@ -422,13 +427,13 @@ export class GoogleSheetsIntegration implements DatasourcePlus {
return rowObject
}
private async createTable(name?: string) {
if (!name) {
throw new Error("Must provide name for new sheet.")
}
private async createTable(table: Table) {
try {
await this.connect()
await this.client.addSheet({ title: name, headerValues: [name] })
await this.client.addSheet({
title: table.name,
headerValues: Object.keys(table.schema),
})
} catch (err) {
console.error("Error creating new table in google sheets", err)
throw err

View File

@ -49,12 +49,11 @@ describe("Google Sheets Integration", () => {
beforeEach(async () => {
nock.cleanAll()
mock = GoogleSheetsMock.forDatasource(datasource)
mock.init()
})
describe("create", () => {
it("creates a new table", async () => {
const table = await config.api.table.save({
await config.api.table.save({
name: "Test Table",
type: "table",
sourceId: datasource._id!,
@ -77,11 +76,10 @@ describe("Google Sheets Integration", () => {
},
})
const cell = mock.getCell(table.name, "A1")
if (!cell) {
throw new Error("Cell not found")
}
expect(cell.userEnteredValue.stringValue).toEqual(table.name)
expect(mock.cell("A1")).toEqual("name")
expect(mock.cell("B1")).toEqual("description")
expect(mock.cell("A2")).toEqual(null)
expect(mock.cell("B2")).toEqual(null)
})
})

View File

@ -3,7 +3,7 @@ import nock from "nock"
import { GoogleSheetsConfig } from "../../googlesheets"
// https://protobuf.dev/reference/protobuf/google.protobuf/#value
type Value = string | number | boolean
type Value = string | number | boolean | null
// https://developers.google.com/sheets/api/reference/rest/v4/Dimension
type Dimension = "ROWS" | "COLUMNS"
@ -195,6 +195,9 @@ export class GoogleSheetsMock {
spreadsheetId: config.spreadsheetId,
sheets: [],
}
this.mockAuth()
this.mockAPI()
}
private route(
@ -237,6 +240,7 @@ export class GoogleSheetsMock {
client_secret: "your-client-secret",
})
.persist()
nock("https://oauth2.googleapis.com/")
.post("/token", {
client_id: "test",
@ -253,9 +257,7 @@ export class GoogleSheetsMock {
.persist()
}
init() {
this.mockAuth()
private mockAPI() {
this.get(`/v4/spreadsheets/${this.config.spreadsheetId}/`, () =>
this.handleGetSpreadsheet()
)
@ -280,7 +282,7 @@ export class GoogleSheetsMock {
if (!range) {
throw new Error("No range provided")
}
return this.handleGetValues(decodeURIComponent(range))
return this.getValueRange(decodeURIComponent(range))
}
)
@ -301,11 +303,17 @@ export class GoogleSheetsMock {
params.includeValuesInResponse = false
}
const range = url.pathname.split("/").pop()
let range = url.pathname.split("/").pop()
if (!range) {
throw new Error("No range provided")
}
if (range.endsWith(":append")) {
range = range.slice(0, -7)
}
range = decodeURIComponent(range)
return this.handleValueAppend({
range,
params,
@ -315,29 +323,44 @@ export class GoogleSheetsMock {
)
}
private handleValueAppend(request: AppendRequest): AppendResponse {}
private handleValueAppend(request: AppendRequest): AppendResponse {
const { range, params, body } = request
const { sheet, bottomRight } = this.parseA1Notation(range)
private handleGetValues(range: string): ValueRange {
const { sheet, topLeft, bottomRight } = this.parseA1Notation(range)
const valueRange: ValueRange = {
range,
majorDimension: "ROWS",
values: [],
}
const newRows = body.values.map(v => this.valuesToRowData(v))
const toDelete =
params.insertDataOption === "INSERT_ROWS" ? newRows.length : 0
sheet.data[0].rowData.splice(bottomRight.row + 1, toDelete, ...newRows)
sheet.data[0].rowMetadata.splice(bottomRight.row + 1, toDelete, {
hiddenByUser: false,
hiddenByFilter: false,
pixelSize: 100,
})
for (let row = topLeft.row; row <= bottomRight.row; row++) {
const values: Value[] = []
for (let col = topLeft.column; col <= bottomRight.column; col++) {
const cell = this.getCellNumericIndexes(sheet, row, col)
if (!cell) {
throw new Error("Cell not found")
}
values.push(this.unwrapValue(cell.userEnteredValue))
const updatedRange = this.createA1FromRanges(
sheet,
{
row: bottomRight.row + 1,
column: 0,
},
{
row: bottomRight.row + newRows.length,
column: 0,
}
valueRange.values.push(values)
}
)
return valueRange
return {
spreadsheetId: this.spreadsheet.spreadsheetId,
tableRange: range,
updates: {
spreadsheetId: this.spreadsheet.spreadsheetId,
updatedRange,
updatedRows: body.values.length,
updatedColumns: body.values[0].length,
updatedCells: body.values.length * body.values[0].length,
updatedData: body,
},
}
}
private handleBatchUpdate(
@ -387,29 +410,9 @@ export class GoogleSheetsMock {
}
private handleValueUpdate(valueRange: ValueRange): UpdateValuesResponse {
if (valueRange.majorDimension !== "ROWS") {
throw new Error("Only row-major updates are supported")
}
const { sheet, topLeft, bottomRight } = this.parseA1Notation(
valueRange.range
)
for (let row = topLeft.row; row <= bottomRight.row; row++) {
for (
let column = topLeft.column;
column <= bottomRight.column;
column++
) {
const cell = this.getCellNumericIndexes(sheet, row, column)
if (!cell) {
continue
}
const value =
valueRange.values[row - topLeft.row][column - topLeft.column]
cell.userEnteredValue = this.createValue(value)
}
}
this.iterateCells(valueRange, (cell, value) => {
cell.userEnteredValue = this.createValue(value)
})
const response: UpdateValuesResponse = {
spreadsheetId: this.spreadsheet.spreadsheetId,
@ -422,6 +425,60 @@ export class GoogleSheetsMock {
return response
}
private iterateCells(
valueRange: ValueRange,
cb: (cell: CellData, value: Value) => void
) {
if (valueRange.majorDimension !== "ROWS") {
throw new Error("Only row-major updates are supported")
}
const { sheet, topLeft, bottomRight } = this.parseA1Notation(
valueRange.range
)
for (let row = topLeft.row; row <= bottomRight.row; row++) {
for (let col = topLeft.column; col <= bottomRight.column; col++) {
const cell = this.getCellNumericIndexes(sheet, row, col)
if (!cell) {
throw new Error("Cell not found")
}
const value = valueRange.values[row - topLeft.row][col - topLeft.column]
cb(cell, value)
}
}
}
private getValueRange(range: string): ValueRange {
const { sheet, topLeft, bottomRight } = this.parseA1Notation(range)
const valueRange: ValueRange = {
range,
majorDimension: "ROWS",
values: [],
}
for (let row = topLeft.row; row <= bottomRight.row; row++) {
const values: Value[] = []
for (let col = topLeft.column; col <= bottomRight.column; col++) {
const cell = this.getCellNumericIndexes(sheet, row, col)
if (!cell) {
throw new Error("Cell not found")
}
values.push(this.unwrapValue(cell.userEnteredValue))
}
valueRange.values.push(values)
}
return valueRange
}
private valuesToRowData(values: Value[]): RowData {
return {
values: values.map(v => {
return { userEnteredValue: this.createValue(v) }
}),
}
}
private unwrapValue(from: ExtendedValue): Value {
if (from.stringValue !== undefined) {
return from.stringValue
@ -432,12 +489,14 @@ export class GoogleSheetsMock {
} else if (from.formulaValue !== undefined) {
return from.formulaValue
} else {
throw new Error("Unsupported value type")
return null
}
}
private createValue(from: Value): ExtendedValue {
if (typeof from === "string") {
if (from == null) {
return {}
} else if (typeof from === "string") {
return {
stringValue: from,
}
@ -459,15 +518,9 @@ export class GoogleSheetsMock {
for (let row = 0; row < numRows; row++) {
const cells: CellData[] = []
for (let col = 0; col < numCols; col++) {
cells.push({
userEnteredValue: {
stringValue: "",
},
})
cells.push({ userEnteredValue: this.createValue(null) })
}
rowData.push({
values: cells,
})
rowData.push({ values: cells })
}
const rowMetadata: DimensionProperties[] = []
for (let row = 0; row < numRows; row++) {
@ -495,16 +548,20 @@ export class GoogleSheetsMock {
}
}
getCell(sheetName: string, ref: string): CellData | undefined {
const sheet = this.getSheetByName(sheetName)
if (!sheet) {
private cellData(cell: string): CellData | undefined {
const {
sheet,
topLeft: { row, column },
} = this.parseA1Notation(cell)
return this.getCellNumericIndexes(sheet, row, column)
}
cell(cell: string): Value | undefined {
const cellData = this.cellData(cell)
if (!cellData) {
return undefined
}
const { row, column } = this.parseCell(ref)
if (row === "ALL" || column === "ALL") {
throw new Error("Only single cell references are supported")
}
return this.getCellNumericIndexes(sheet, row, column)
return this.unwrapValue(cellData.userEnteredValue)
}
private getCellNumericIndexes(
@ -525,36 +582,83 @@ export class GoogleSheetsMock {
}
// https://developers.google.com/sheets/api/guides/concepts#cell
//
// Examples from
// https://code.luasoftware.com/tutorials/google-sheets-api/google-sheets-api-range-parameter-a1-notation
//
// "Sheet1!A1" -> First cell on Row 1 Col 1
// "Sheet1!A1:C1" -> Col 1-3 (A, B, C) on Row 1 = A1, B1, C1
// "A1" -> First visible sheet (if sheet name is ommitted)
// "'My Sheet'!A1" -> If sheet name which contain space or start with a bracket.
// "Sheet1" -> All cells in Sheet1.
// "Sheet1!A:A" -> All cells on Col 1.
// "Sheet1!A:B" -> All cells on Col 1 and 2.
// "Sheet1!1:1" -> All cells on Row 1.
// "Sheet1!1:2" -> All cells on Row 1 and 2.
//
// How that translates to our code below, omitting the `sheet` property:
//
// "Sheet1!A1" -> { topLeft: { row: 0, column: 0 }, bottomRight: { row: 0, column: 0 } }
// "Sheet1!A1:C1" -> { topLeft: { row: 0, column: 0 }, bottomRight: { row: 0, column: 2 } }
// "A1" -> { topLeft: { row: 0, column: 0 }, bottomRight: { row: 0, column: 0 } }
// "Sheet1" -> { topLeft: { row: 0, column: 0 }, bottomRight: { row: 100, column: 25 } }
// -> This is because we default to having a 100x26 grid.
// "Sheet1!A:A" -> { topLeft: { row: 0, column: 0 }, bottomRight: { row: 99, column: 0 } }
// "Sheet1!A:B" -> { topLeft: { row: 0, column: 0 }, bottomRight: { row: 99, column: 1 } }
// "Sheet1!1:1" -> { topLeft: { row: 0, column: 0 }, bottomRight: { row: 0, column: 25 } }
// "Sheet1!1:2" -> { topLeft: { row: 0, column: 0 }, bottomRight: { row: 1, column: 25 } }
private parseA1Notation(range: string): {
sheet: Sheet
topLeft: Range
bottomRight: Range
} {
let [sheetName, rest] = range.split("!")
let sheet: Sheet
let rest: string
if (!range.includes("!")) {
sheet = this.spreadsheet.sheets[0]
rest = range
} else {
let sheetName = range.split("!")[0]
if (sheetName.startsWith("'") && sheetName.endsWith("'")) {
sheetName = sheetName.slice(1, -1)
}
const foundSheet = this.getSheetByName(sheetName)
if (!foundSheet) {
throw new Error(`Sheet ${sheetName} not found`)
}
sheet = foundSheet
rest = range.split("!")[1]
}
const [topLeft, bottomRight] = rest.split(":")
if (sheetName.startsWith("'") && sheetName.endsWith("'")) {
sheetName = sheetName.slice(1, -1)
const parsedTopLeft = topLeft ? this.parseCell(topLeft) : undefined
let parsedBottomRight = bottomRight
? this.parseCell(bottomRight)
: undefined
if (!parsedTopLeft && !parsedBottomRight) {
throw new Error("No range provided")
}
const sheet = this.getSheetByName(sheetName)
if (!sheet) {
throw new Error(`Sheet ${sheetName} not found`)
if (!parsedTopLeft) {
throw new Error("No top left cell provided")
}
const parsedTopLeft = this.parseCell(topLeft)
const parsedBottomRight = this.parseCell(bottomRight)
if (!parsedBottomRight) {
parsedBottomRight = parsedTopLeft
}
if (parsedTopLeft.row === "ALL") {
if (parsedTopLeft && parsedTopLeft.row === undefined) {
parsedTopLeft.row = 0
}
if (parsedBottomRight.row === "ALL") {
parsedBottomRight.row = sheet.properties.gridProperties.rowCount - 1
}
if (parsedTopLeft.column === "ALL") {
if (parsedTopLeft && parsedTopLeft.column === undefined) {
parsedTopLeft.column = 0
}
if (parsedBottomRight.column === "ALL") {
if (parsedBottomRight && parsedBottomRight.row === undefined) {
parsedBottomRight.row = sheet.properties.gridProperties.rowCount - 1
}
if (parsedBottomRight && parsedBottomRight.column === undefined) {
parsedBottomRight.column = sheet.properties.gridProperties.columnCount - 1
}
@ -565,22 +669,31 @@ export class GoogleSheetsMock {
}
}
private createA1FromRanges(sheet: Sheet, topLeft: Range, bottomRight: Range) {
let title = sheet.properties.title
if (title.includes(" ")) {
title = `'${title}'`
}
const topLeftLetter = this.numberToLetter(topLeft.column)
const bottomRightLetter = this.numberToLetter(bottomRight.column)
const topLeftRow = topLeft.row + 1
const bottomRightRow = bottomRight.row + 1
return `${title}!${topLeftLetter}${topLeftRow}:${bottomRightLetter}${bottomRightRow}`
}
/**
* Parses a cell reference into a row and column.
* @param cell a string of the form A1, B2, etc.
* @returns
*/
private parseCell(cell: string): {
row: number | "ALL"
column: number | "ALL"
} {
private parseCell(cell: string): Partial<Range> {
const firstChar = cell.slice(0, 1)
if (this.isInteger(firstChar)) {
return { row: parseInt(cell) - 1, column: "ALL" }
return { row: parseInt(cell) - 1 }
}
const column = this.letterToNumber(firstChar)
if (cell.length === 1) {
return { row: "ALL", column }
return { column }
}
const number = cell.slice(1)
return { row: parseInt(number) - 1, column }
@ -594,6 +707,10 @@ export class GoogleSheetsMock {
return letter.charCodeAt(0) - 65
}
private numberToLetter(number: number): string {
return String.fromCharCode(number + 65)
}
private getSheetByName(name: string): Sheet | undefined {
return this.spreadsheet.sheets.find(
sheet => sheet.properties.title === name