Google Spread Sheet API v4を使ってToDoのCRUD操作を行ったメモ

## 概要

[前回](https://qiita.com/hibohiboo/items/cc29225a0b01563ca9bd)では、Spread Sheetを操作できる環境を作った。
次は、CRUD操作を試してみる。

基本的には[api v4 リファレンス](https://developers.google.com/sheets/api/reference/rest)と[samples](https://developers.google.com/sheets/api/samples/rowcolumn#delete_rows_or_columns)の通り。
SQLでいうWHEREをうまくやる方法が見つからなかったので、行番号を出力して使用するようにした。
絞り込みはフィルターを作成して、取得すればできそうな気もしますが面倒そうで、スプレッドシート側でやることに。
簡単に条件を付ける方法ご存じの方いらっしゃれば教えてください。

[ソース](https://github.com/hibohiboo/todo-app-with-spreadsheet/tree/b7546de631431cdd87ea0b5918bfc5d10ba7b4ef/api)
[スプレッドシート](https://docs.google.com/spreadsheets/d/1hZqoSuUMdLn6bwWN_ByYOS3C9Ra-VqZPDBLjJYNJnOo/edit#gid=0)

## スプレッドシート
### 基本の情報シート
A1セルに`=ARRAYFORMULA(if(B:B="","",row(B:B)-1))`を入れて、行番号が出力されるようにしている。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/99876/1a5e8a9a-e2bd-35fd-ec97-691b957f9445.png)![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/99876/1448221b-a274-985a-0645-9412939baff3.png)

### 絞り込みシート
A1セルに`=query(Todo!A:D,"select * where D <> TRUE", 1)`を入れて、絞り込んだ結果を表示するようにしている。
プログラムからは、このシートを参照するだけ。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/99876/a9f630b6-dc79-b589-c370-46dfe8ce0b3e.png)

## プログラム
### 認証
```js
const private_key = process.env.SERVICE_ACCOUNT_PRIVATE_KEY.replace('\\\\','\\',)
async function getAuth() {
  const auth = await google.auth.getClient({
    credentials: {
      client_email: process.env.SERVICE_ACCOUNT_CLIENT_EMAIL,
      private_key,
    },
    scopes: ['https://www.googleapis.com/auth/spreadsheets'],
  })
  return auth
}
```

### CREATE

```js
  create: async (todo) => {
    const auth = await getAuth()
    const req = {
      auth,
      spreadsheetId: spreadsheetId,
      range: 'A1',
      valueInputOption: 'USER_ENTERED',
      insertDataOption: 'INSERT_ROWS',
      resource: {
        values: [['', ...values(todo)]],
      },
    }
    await sheets.spreadsheets.values.append(req)
  },
```
### READ
* 全件取得
 
```ts
async function fetchAll(spreadsheetId: string, range: string) {
  const baseSheetName = 'Todo'
  const auth = await getAuth()
  const apiOptions = {
    auth,
    spreadsheetId,
    range: `${baseSheetName}!${range}`,
  }
  const res = await sheets.spreadsheets.values.get(apiOptions)
  if (!res.data || !res.data.values) return []
  return res.data.values
}
```

* 絞り込み（そんなに行が多くなる想定がないのでとりあえず100件で十分としている）

```ts
async function fetchCompleted(isCompleted) {
  const completedSheetName = 'TodoCompleted'
  const uncompletedSheetName = 'TodoUncompleted'
  const sheetName = isCompleted ? completedSheetName : uncompletedSheetName

  const auth = await getAuth()
  const apiOptions = {
    auth,
    spreadsheetId,
    range: `${sheetName}!A2D100`,
  }
  const res = await sheets.spreadsheets.values.get(apiOptions)
  if (!res.data.values) return []
  // console.log('fetchres', res.data)
  return res.data.values
}
```

* 指定行を1行取得

```ts
async function fetch(rowNumber): Promise<null | string[]> {
  const auth = await getAuth()
  const request = {
    spreadsheetId,
    resource: {
      dataFilters: [
        {
          gridRange: {
            sheetId: baseSheetId,
            startRowIndex: rowNumber,
            endRowIndex: rowNumber + 1,
          },
        },
      ],
    },
    auth,
  }
  try {
    const response = (await sheets.spreadsheets.values.batchGetByDataFilter(request)).data
    if (!response.valueRanges || response.valueRanges.length === 0) return null
    return response.valueRanges[0].valueRange.values[0]
  } catch (err) {
    console.error(err)
    return null
  }
}
```
### UPDATE

```ts
  update: async (rowNumber, update) => {
    const auth = await getAuth()
    const arr = await fetch(rowNumber)
    if (!arr) return null
    const todo = convertSheetArrayToToDo(arr)
    const updatedTodo = { ...todo, ...update }
    const [_, ...updateValues] = values(updatedTodo)

    const request = {
      spreadsheetId,
      resource: {
        valueInputOption: 'USER_ENTERED',
        data: [
          {
            dataFilter: {
              gridRange: {
                sheetId: 0,
                startRowIndex: rowNumber,
                endRowIndex: rowNumber + 1,
                startColumnIndex: 1,
                endColumnIndex: 4,
              },
            },
            values: [updateValues],
            majorDimension: 'ROWS',
          },
        ],
      },
      auth,
    }
    try {
      const response = (await sheets.spreadsheets.values.batchUpdateByDataFilter(request)).data
      if (response.totalUpdatedRows !== 1) return null
    } catch (err) {
      console.error(err)
    }
    return updatedTodo
  }
```

### DELETE
* 指定行を削除

```ts
async function remove(rowNumber: number) {
  const auth = await getAuth()
  const req = {
    auth,
    spreadsheetId: spreadsheetId,
    resource: {
      requests: [
        {
          deleteDimension: {
            range: {
              sheetId: baseSheetId,
              dimension: 'ROWS',
              startIndex: rowNumber,
              endIndex: rowNumber + 1,
            },
          },
        },
      ],
    },
  }
  await sheets.spreadsheets.batchUpdate(req)
  return start
}

```

### 型定義

```ts
export type ID = string
export interface Todo {
  rowNumber: number
  id: ID
  title: string
  completed: boolean
}

export type OptionalKeys<T> = { [K in keyof T]?: T[K] | null }
export interface DataStorage<T extends { rowNumber: number }> {
  fetchAll: () => Promise<T[]>
  fetchByCompleted: (completed: boolean) => Promise<T[]>
  create: (todo: Omit<T, 'rowNumber'>) => Promise<void>
  update: (rowNumber: number, update: OptionalKeys<T>) => Promise<T | null>
  remove: (rowNumber: number) => Promise<number | null>
}
```

## 参考
[Googleスプレッドシートをプログラムから操作](https://qiita.com/howdy39/items/ca719537bba676dce1cf)
[NOWでデプロイできるGoogleフォームみたいに追記できるAPIを作ったので、GitHubに公開してみた](https://qiita.com/kira_puka/items/3fcb83a0e89153038435)