├── .eslintrc.js
├── .gitignore
├── LICENSE
├── README.md
├── docs
├── .nojekyll
├── README.md
├── _sidebar.md
├── assets
│ ├── imgs
│ │ ├── favicon.svg
│ │ └── logo.svg
│ └── style.css
├── classdocs
│ ├── sheetdatabase.md
│ └── table.md
├── getting-started
│ └── authentication.md
└── index.html
├── package.json
├── src
├── dbhelper
│ ├── Database.ts
│ ├── ResponseStructure.ts
│ ├── Table.ts
│ ├── actions.ts
│ └── utils.ts
└── index.ts
├── test
├── auth.test.ts
├── entries.test.ts
├── load_database.ts
├── names.test.ts
└── tables.test.ts
└── tsconfig.json
/.eslintrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | env: {
3 | es2021: true,
4 | node: true,
5 | },
6 | extends: [
7 | 'google', 'plugin:@typescript-eslint/recommended',
8 | ],
9 | rules: {
10 | '@typescript-eslint/explicit-module-boundary-types': 'off',
11 | 'require-jsdoc': ['error', {
12 | 'require': {
13 | 'FunctionDeclaration': false,
14 | 'MethodDefinition': false,
15 | 'ClassDeclaration': false,
16 | 'ArrowFunctionExpression': false,
17 | 'FunctionExpression': false,
18 | },
19 | }],
20 | 'max-len': ['error', {
21 | 'ignoreComments': true,
22 | 'ignoreStrings': true,
23 | 'code': 120,
24 | }],
25 | 'valid-jsdoc': ['error', {
26 | 'requireParamType': false,
27 | 'requireReturn': false,
28 | 'requireReturnType': false,
29 | }],
30 | 'camelcase': ['off'],
31 | 'no-console': ['error'],
32 | },
33 | parser: '@typescript-eslint/parser',
34 | parserOptions: {
35 | ecmaVersion: 2020,
36 | sourceType: 'module',
37 | },
38 | plugins: ['@typescript-eslint'],
39 | };
40 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | package-lock.json
2 | lib/
3 | src/creds.json
4 | test/API_KEY.ts
5 | test/creds.json
6 | # Logs
7 | logs
8 | *.log
9 | npm-debug.log*
10 |
11 | # Diagnostic reports (https://nodejs.org/api/report.html)
12 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
13 |
14 | # Runtime data
15 | pids
16 | *.pid
17 | *.seed
18 | *.pid.lock
19 |
20 | # Dependency directories
21 | node_modules/
22 | jspm_packages/
23 |
24 |
25 | # TypeScript cache
26 | *.tsbuildinfo
27 |
28 | # Optional npm cache directory
29 | .npm
30 |
31 | # Optional eslint cache
32 | .eslintcache
33 |
34 | # Output of 'npm pack'
35 | *.tgz
36 |
37 | # dotenv environment variables file
38 | .env
39 | .env.test
40 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2020 Rahul Jha
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | [](https://www.npmjs.com/package/sheets-database)
6 | [](https://raw.githubusercontent.com/rahul-jha98/sheets-database/main/LICENSE)
7 |
8 | > Library to help use a Google Sheet as a database (or CMS)
9 |
10 |
11 | ## Features
12 | - Simple & Intuitive API
13 | - Supports most of the simple operations needed in a database
14 | - Multiple auth options - Service Account, OAuth, Access Token and API Key
15 | - Provides method to reduce memory and network usage to optimize for your use case.
16 |
17 | **Docs site -**
18 | Full docs available at [https://rahul-jha98.github.io/sheets-database/](https://rahul-jha98.github.io/sheets-database/)
19 |
20 |
21 | > 🚀 **Installation** - `npm i sheets-database --save` or `yarn add sheets-database`
22 |
23 | ## Examples
24 | _the following examples are meant to give you an idea of just some of the things you can do_
25 |
26 | > **IMPORTANT NOTE** - To keep the examples concise, I'm calling await [at the top level](https://v8.dev/features/top-level-await) which is not allowed by default in most versions of node. If you need to call await in a script at the root level, you must instead wrap it in an async function.
27 |
28 |
29 | ### Working with Tables
30 | ```javascript
31 | const { SheetDatabase } = require('sheets-database');
32 |
33 | // Initialize the Database with doc ID (long id in the sheets URL)
34 | const db = new SheetDatabase('');
35 |
36 | // Initialize Auth
37 | // see more available options at https://rahul-jha98.github.io/sheets-database/#/getting-started/authentication
38 | await db.useServiceAccount({
39 | client_email: process.env.GOOGLE_SERVICE_ACCOUNT_EMAIL,
40 | private_key: process.env.GOOGLE_PRIVATE_KEY,
41 | });
42 |
43 | await db.sync(); // actually connecting with sheet and fetching data
44 |
45 | // ADDING TABLES
46 | const table1 = await db.addTable('table1', ['column1', 'column2', 'column3']);
47 | const table2 = await db.addTable('table2', ['column1', 'column2']);
48 |
49 | // RENAMING TABLES
50 | await table1.rename('newTable1');
51 |
52 | await db.renameTable('table2', 'newTable2');
53 |
54 |
55 | // DELETING TABLES
56 | await db.newTable1.drop();
57 |
58 | await db.dropTable('newTable2');
59 | ```
60 | More info:
61 | - [Authentication](https://rahul-jha98.github.io/sheets-database#/getting-started/authentication)
62 | - [SheetDatabase](https://rahul-jha98.github.io/sheets-database/#/classdocs/sheetdatabase)
63 |
64 |
65 | ### Working with Table Entries
66 | ```javascript
67 | // add a new table
68 | const table = await db.addTable('entries', ['name', 'age']);
69 |
70 | // Insert Single Entry
71 | await table.insertOne({'name': 'Micheal Scott', 'age': 43});
72 |
73 | // Insert Multiple Entries
74 | await table.insert([
75 | {'name': 'Jim Halpert', 'age': 30},
76 | ['Dwight Schrute', 35]
77 | ]);
78 |
79 | console.log(table.getData());
80 | /**
81 | * [
82 | * {name: 'Micheal Scott', age: 43},
83 | * {name: 'Jim Halpert', age: 30},
84 | * {name: 'Dwight Schrute', age: 35}
85 | * ]
86 | */
87 |
88 | // Update Rows
89 | // Here we add 10 to all the rows where current age is less than 40
90 | await table.updateRowsWhere(
91 | (currentData) => (currentData.age < 40),
92 | (data) => {
93 | return {age: data.age + 10}
94 | });
95 |
96 | console.log(table.getData());
97 | /**
98 | * [
99 | * {name: 'Micheal Scott', age: 43},
100 | * {name: 'Jim Halpert', age: 40},
101 | * {name: 'Dwight Schrute', age: 45}
102 | * ]
103 | */
104 |
105 | // Delete Rows
106 | await table.deleteRowsWhere((data) => data.name === 'Micheal Scott');
107 | console.log(table.getData());
108 | /**
109 | * [
110 | * {name: 'Jim Halpert', age: 40},
111 | * {name: 'Dwight Schrute', age: 45}
112 | * ]
113 | */
114 | ```
115 | More Info:
116 | - [Table](https://rahul-jha98.github.io/sheets-database//#/classdocs/table)
117 |
118 | ## Why?
119 | > The library will let you worry only about the CRUD operation you wish to perfrom and handles the task of updating it to the spreadsheet internally.
120 |
121 | Do you ever wonder if you can use Google Sheets as a no-cost database? Well, if your application deals with lot of entries and joins across tables than of course it isn't such a good idea. But if you have a **small application or a static website that needs very few dynamic content** there is no point in having a backend that deals with a database to serve those content since you could easily use a Google Sheet to store the data. You could also consider this as an option to get the frontend part's development started by using Google Sheet as a mock database while the actual backend is being built.
122 |
123 | But the Google Sheet's API v4 is a bit awkward with confusing docs, at least to get started. Moreover, the API is not designed to use Sheets API as a database which is why you would require you to deal with the rows and columns data manually to deal with data. With such a steep learning curve to get started the prospect of using it as a database doesn't seems like a good deal.
124 |
125 | The library aims to remove the learning curve completely by providing methods that lets you interact with the database without worrying about the Sheets API at all.
126 | Moreover the API of the library is quite intuitive to get started with and provides functionalities for most of the database operations.
127 |
128 | ## Note
129 | `sheets-database` is heavily inspired by and also borrows some code from [node-google-spreadsheet](https://github.com/theoephraim/node-google-spreadsheet).
130 |
131 | ## Contributions
132 |
133 | This module was written by [Rahul Jha](https://github.com/rahul-jha98).
134 |
135 | Contributions are welcome. Make sure to add relevant documentation along with code changes.
136 | Also, since I am new to Typescript and still exploring any help in improving the code practices and conventions would be appreciated.
137 |
138 | The docs site is generated using [docsify](https://docsify.js.org). To preview and run locally so you can make edits, install docsify_cli and run `docsify serve ./docs` in the project root folder and head to http://localhost:3000
139 | The content lives in markdown files in the docs folder.
140 |
141 | ## License
142 | [MIT](https://github.com/rahul-jha98/sheets-database/blob/main/LICENSE)
143 |
--------------------------------------------------------------------------------
/docs/.nojekyll:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/rahul-jha98/sheets-database/168ca2b543860bb4af727d46d7378065bd39243d/docs/.nojekyll
--------------------------------------------------------------------------------
/docs/README.md:
--------------------------------------------------------------------------------
1 | _Welcome to the documentation site for_
2 |
3 |
4 |
5 |
6 | [](https://www.npmjs.com/package/sheets-database)
7 | [](https://raw.githubusercontent.com/rahul-jha98/sheets-database/main/LICENSE)
8 |
9 | > Library to help use a Google Sheet as a database (or CMS)
10 |
11 |
12 | ## Features
13 | - Simple & Intuitive API
14 | - Supports most of the simple operations needed in a database
15 | - Multiple auth options - Service Account, OAuth, Access Token and API Key
16 | - Provides method to reduce memory and network usage to optimize for your use case.
17 |
18 |
20 |
21 |
22 | > 🚀 **Installation** - `npm i sheets-database --save` or `yarn add sheets-database`
23 |
24 | ## Examples
25 | _the following examples are meant to give you an idea of just some of the things you can do_
26 |
27 | > **IMPORTANT NOTE** - To keep the examples concise, I'm calling await [at the top level](https://v8.dev/features/top-level-await) which is not allowed by default in most versions of node. If you need to call await in a script at the root level, you must instead wrap it in an async function.
28 |
29 |
30 | ### Working with Tables
31 | ```javascript
32 | const { SheetDatabase } = require('sheets-database');
33 |
34 | // Initialize the Database with doc ID (long id in the sheets URL)
35 | const db = new SheetDatabase('');
36 |
37 | // Initialize Auth
38 | // see more available options at https://rahul-jha98.github.io/sheets-database/#/getting-started/authentication
39 | await db.useServiceAccount({
40 | client_email: process.env.GOOGLE_SERVICE_ACCOUNT_EMAIL,
41 | private_key: process.env.GOOGLE_PRIVATE_KEY,
42 | });
43 |
44 | await db.sync(); // actually connecting with sheet and fetching data
45 |
46 | // ADDING TABLES
47 | const table1 = await db.addTable('table1', ['column1', 'column2', 'column3']);
48 | const table2 = await db.addTable('table2', ['column1', 'column2']);
49 |
50 | // RENAMING TABLES
51 | await table1.rename('newTable1');
52 |
53 | await db.renameTable('table2', 'newTable2');
54 |
55 |
56 | // DELETING TABLES
57 | await db.newTable1.drop();
58 |
59 | await db.dropTable('newTable2');
60 | ```
61 | More info:
62 | - [Authentication](https://rahul-jha98.github.io/sheets-database#/getting-started/authentication)
63 | - [SheetDatabase](https://rahul-jha98.github.io/sheets-database/#/classdocs/sheetdatabase)
64 |
65 |
66 | ### Working with Table Entries
67 | ```javascript
68 | // add a new table
69 | const table = await db.addTable('entries', ['name', 'age']);
70 |
71 | // Insert Single Entry
72 | await table.insertOne({'name': 'Micheal Scott', 'age': 43});
73 |
74 | // Insert Multiple Entries
75 | await table.insert([
76 | {'name': 'Jim Halpert', 'age': 30},
77 | ['Dwight Schrute', 35]
78 | ]);
79 |
80 | console.log(table.getData());
81 | /**
82 | * [
83 | * {name: 'Micheal Scott', age: 43},
84 | * {name: 'Jim Halpert', age: 30},
85 | * {name: 'Dwight Schrute', age: 35}
86 | * ]
87 | */
88 |
89 | // Update Rows
90 | // Here we add 10 to all the rows where current age is less than 40
91 | await table.updateRowsWhere(
92 | (currentData) => (currentData.age < 40),
93 | (data) => {
94 | return {age: data.age + 10}
95 | });
96 |
97 | console.log(table.getData());
98 | /**
99 | * [
100 | * {name: 'Micheal Scott', age: 43},
101 | * {name: 'Jim Halpert', age: 40},
102 | * {name: 'Dwight Schrute', age: 45}
103 | * ]
104 | */
105 |
106 | // Delete Rows
107 | await table.deleteRowsWhere((data) => data.name === 'Micheal Scott');
108 | console.log(table.getData());
109 | /**
110 | * [
111 | * {name: 'Jim Halpert', age: 40},
112 | * {name: 'Dwight Schrute', age: 45}
113 | * ]
114 | */
115 | ```
116 | More Info:
117 | - [Table](https://rahul-jha98.github.io/sheets-database//#/classdocs/table)
118 |
119 | ## Why?
120 | > The library will let you worry only about the CRUD operation you wish to perfrom and handles the task of updating it to the spreadsheet internally.
121 |
122 | Do you ever wonder if you can use Google Sheets as a no-cost database? Well, if your application deals with lot of entries and joins across tables than of course it isn't such a good idea. But if you have a **small application or a static website that needs very few dynamic content** there is no point in having a backend that deals with a database to serve those content since you could easily use a Google Sheet to store the data. You could also consider this as an option to get the frontend part's development started by using Google Sheet as a mock database while the actual backend is being built.
123 |
124 | But the Google Sheet's API v4 is a bit awkward with confusing docs, at least to get started. Moreover, the API is not designed to use Sheets API as a database which is why you would require you to deal with the rows and columns data manually to deal with data. With such a steep learning curve to get started the prospect of using it as a database doesn't seems like a good deal.
125 |
126 | The library aims to remove the learning curve completely by providing methods that lets you interact with the database without worrying about the Sheets API at all.
127 | Moreover the API of the library is quite intuitive to get started with and provides functionalities for most of the database operations.
128 |
129 | ## Note
130 | `sheets-database` is heavily inspired by and borrows some code from [node-google-spreadsheet](https://github.com/theoephraim/node-google-spreadsheet).
131 |
132 | ## Contributions
133 |
134 | This module was written by [Rahul Jha](https://github.com/rahul-jha98).
135 |
136 | Contributions are welcome. Make sure to add relevant documentation along with code changes.
137 | Also, since I am new to Typescript and still exploring any help in improving the code practices and conventions would be appreciated.
138 |
139 | The docs site is generated using [docsify](https://docsify.js.org). To preview and run locally so you can make edits, install docsify_cli and run `docsify serve ./docs` in the project root folder and head to http://localhost:3000
140 | The content lives in markdown files in the docs folder.
141 |
142 | ## License
143 | [MIT](https://github.com/rahul-jha98/sheets-database/blob/main/LICENSE)
144 |
--------------------------------------------------------------------------------
/docs/_sidebar.md:
--------------------------------------------------------------------------------
1 |
2 | * **Getting Started**
3 | * [Overview](/ "sheets-database")
4 | * [Authentication](getting-started/authentication)
5 | * **Class Documentation**
6 | * [SheetDatabase](classdocs/sheetdatabase)
7 | * [Table](classdocs/table)
8 |
--------------------------------------------------------------------------------
/docs/assets/imgs/favicon.svg:
--------------------------------------------------------------------------------
1 |
14 |
--------------------------------------------------------------------------------
/docs/assets/imgs/logo.svg:
--------------------------------------------------------------------------------
1 |
9 |
--------------------------------------------------------------------------------
/docs/assets/style.css:
--------------------------------------------------------------------------------
1 | table th { text-align: left; }
2 |
3 |
4 | .sidebar h1 {
5 | margin-left: 1em;
6 | margin-right: 1em;
7 | }
8 | h3 code { font-size: inherit !important; }
9 | h4 code { font-size: inherit !important; color: unset; }
10 |
11 | h4[id^="method-"] {
12 | margin-top: 2em;
13 | margin-left: -.3em;
14 | margin-bottom: -.6em;
15 | }
16 | h4[id^="method-"] code::before {
17 | display: inline-block;
18 | content: '⚙️';
19 | padding-right: .3em;
20 | }
21 |
22 | h4[id^="method-"] + blockquote {
23 | margin: 0;
24 | border-left-color: #e96900;
25 | border-left: none;
26 | /* font-style: italic; */
27 | padding: 0;
28 | }
29 | :root {
30 | --theme-color: #43A047;
31 | --theme-color-dark: #43A047;
32 |
33 | font-family: 'roboto';
34 | }
35 | h4[id^="method-"] a code {
36 | /* color: unset; */
37 | color: #43A047;
38 | }
39 | .markdown-section p.tip {
40 | margin: 1em 1em
41 | }
--------------------------------------------------------------------------------
/docs/classdocs/sheetdatabase.md:
--------------------------------------------------------------------------------
1 | _Class Documentation_
2 |
3 | # SheetDatabase
4 |
5 | > **This class represents the entire Database object**
6 |
7 | It act as the main gateway to interact with the tables and table entries in the Database.
8 |
9 | ## Initializing with Sheet ID
10 |
11 | `new SheetDatabase(sheetId);`
12 |
13 | Param|Type|Description
14 | ---|---|---
15 | `sheetId`|string|Document ID for the Google Sheet Document
16 |
17 |
18 | ## Properties
19 | Once initialized the Database with a Google Sheet, call `db.sync()` to actually connect with the Google Sheet Document and fetch the data in memory. Once the data has been loaded the following readonly properties can then be accessed.
20 |
21 | Property|Type|Description
22 | ---|---|---
23 | `sheetId`|string|Sheet ID of the Google Sheet _unique id for sheet, not editable_
24 | `title`|string|Title of the Google Sheet
25 | `tables`|Record|Tables of database, keyed by their `title`
26 | `tablesByIndex`|Array.<[Table](classdocs/table)>|Array of tables, ordered by their index _The index is the position of individual sheet in the Document_
27 |
28 | ## Methods
29 |
30 | ### Authentication
31 |
32 | #### `useApiKey(key)` :id=method-useApiKey
33 | > Set API-key to use for auth - only allows read-only access to public docs.
34 |
35 | Param|Type|Required|Description
36 | ---|---|---|---
37 | `key`|string|✅|API key for your google project
38 |
39 | - ✨ **Side effects** - all requests will now authenticate using this api key only
40 |
41 | > See [Getting Started > Authentication > API Key](getting-started/authentication#api-key) for more details
42 |
43 | #### `useServiceAccountAuth(creds)` (async) :id=method-useServiceAccountAuth
44 | > Initialize JWT-style auth for [google service account](https://cloud.google.com/iam/docs/service-accounts)
45 |
46 | Param|Type|Required|Description
47 | ---|---|---|---
48 | `creds`|Object|✅|Object containing credendtials from google for your service account _usually just `require` the json file google gives you_
49 | `creds.client_email`|String _email_|✅|The email of your service account
50 | `creds.private_key`|String|✅|The private key for your service account
51 |
52 | - ✨ **Side effects** - all requests will now authenticate using these credentials
53 |
54 | > See [Getting Started > Authentication > Service Account](getting-started/authentication#service-account) for more details
55 |
56 | #### `useOAuth2Client(oAuth2Client)` :id=method-useOAuth2Client
57 | > Use [Google's OAuth2Client](https://github.com/googleapis/google-auth-library-nodejs#oauth2) to authenticate on behalf of a user
58 |
59 | Param|Type|Required|Description
60 | ---|---|---|---
61 | `oAuth2Client`|OAuth2Client|✅|Configured OAuth2Client
62 |
63 | - ✨ **Side effects** - requests will use oauth access token to authenticate requests. New access token will be generated if token is expired.
64 |
65 | > See [Getting Started > Authentication > OAuth Login](getting-started/authentication#oauth) for more details
66 |
67 | #### `useAccessToken(token)` :id=method-useAccessToken
68 | > Set access token to use for auth
69 |
70 | Param|Type|Required|Description
71 | ---|---|---|---
72 | `token`|string|✅|Oauth token to use
73 |
74 | - ✨ **Side effects** - all requests will now authenticate using this OAuth Token
75 |
76 | !> This assumes you are creating and managing/refreshing the token yourself.
77 |
78 | > See [Getting Started > Authentication > Access Token](getting-started/authentication#aceess-token) for more details
79 | ### Load data from Sheet
80 |
81 | #### `sync(syncTableEntries)` (async) :id=method-sync
82 | > Loads all the tables and its entries into memory
83 |
84 | Param|Type|Required|Description
85 | ---|---|---|---
86 | `syncTableEntries`|boolean|-|Whether to also load the entries of tables _default = true_
87 | - ✨ **Side effects** - all tables and their headers are fetched.
88 |
89 | ### Managing Tables
90 | #### `getTable(tableName)` :id=method-getTable
91 | > returns the table with the given table name
92 |
93 | Param|Type|Required|Description
94 | ---|---|---|---
95 | `tableName`|string|✅|name of the table
96 | - ↩️ **Returns** - [Table](classdocs/table) in database with the given name
97 |
98 | !> If no table with given table name if found it throws an error
99 |
100 | #### `addTable(tableName, columnNames)` (async) :id=method-addTable
101 | > adds a table to database (by adding a new sheet to the Document)
102 |
103 | Param|Type|Required|Description
104 | ---|---|---|---
105 | `tableName`|string|✅|name of the added table
106 | `columnNames`|Array.< string >|✅|list of column names in the new table
107 | - ↩️ **Returns** - Promise.<[Table](classdocs/table)> (reference to the added table)
108 | - ✨ **Side effects** - table is accessible via (`db.tablesByIndex`, `db.tables`, `db.getTable()`)
109 |
110 | #### `dropTable(tableName)` (async) :id=method-dropTable
111 | > drop the table with the given name
112 |
113 | Param|Type|Required|Description
114 | ---|---|---|---
115 | `tableName`|string|✅|name of the table to delete
116 | - ✨ **Side effects** - table is no longer accessible via (`db.tablesByIndex`, `db.tables`, `db.getTable()`)
117 |
118 | !> If no table with given table name if found it throws an error
119 |
120 | #### `renameTable(tableName, newTableName)` (async) :id=method-renameTable
121 | > rename the table with the given name
122 |
123 | Param|Type|Required|Description
124 | ---|---|---|---
125 | `tableName`|string|✅|name of the table to rename
126 | `newTableName`|string|✅|new name of the table
127 | - ✨ **Side effects** - table is no longer accessible via its old name.
128 |
129 | !> If no table with given table name if found it throws an error
--------------------------------------------------------------------------------
/docs/classdocs/table.md:
--------------------------------------------------------------------------------
1 | _Class Documentation_
2 |
3 | # Table
4 |
5 | > **Represents each Table in the Database (i.e. each Worksheet in Google Sheet Document)**
6 |
7 | It exposes method to read and manipulate data of the table.
8 |
9 | ## Properties
10 | When you load the data of the table the following read only properties are populated.
11 |
12 | ### Basic Properties
13 | Property|Type|Description
14 | ---|---|---
15 | `name`|string|Name of the Table _Also referred as `title`_
16 | `index`|number _int >= 0_|Index of the sheet in Document
17 | `sheetId`|number|Id of the sheet being used as table
18 | `rowCount`|number _int >=1_|number of rows in the sheet
19 | `columnCount`|number _int >= 1_|number of columns in the sheet
20 |
21 | ### Properties related to entries
22 | Property|Type|Description
23 | ---|---|---
24 | `length`|number|Number of entries in the table
25 | `columnNames`|Array.< string >|List of column names in the table
26 | `data`|Array.< Object >|returns value of [`getData()`](#method-getData)
27 |
28 |
29 | ## Methods
30 |
31 | ### Setting the Column Names
32 |
33 | #### `loadColumnNames(enforceHeaders)` (async) :id=method-loadColumnNames
34 | > Loads the first row of the sheet (Which is used to save column names) _usually do not need to call this directly_
35 |
36 | Param|Type|Required|Description
37 | ---|---|---|---
38 | `enforceHeaders`|boolean|- _default = false_|If true an error is thrown if header row is empty
39 |
40 | - ✨ **Side effects** - `table.columnNames` is populated
41 |
42 | #### `setColumnNames(headerValues, shrinkTable)` (async) :id=method-setColumnNames
43 | > Set the column names of the table (first row of the sheet)
44 |
45 | Param|Type|Required|Description
46 | ---|---|---|---
47 | `headerValues`|Array.< string >|✅|Array of string to set as column names
48 | `shrinkTable`|boolean|- _default = false_|Pass true if you want to delete the extra columns in sheet _(Recommended to set true)_
49 |
50 | - ✨ **Side effects** - the first row of sheet is updates and `table.columnNames` is set to new value
51 |
52 |
53 | ### Reading Table Entries
54 |
55 | #### `getData()` :id=method-getData
56 | > Get array of objects with row values keyed by column names
57 |
58 |
59 | - ↩️ **Returns** - Array< Object > of entries stored in table **[ { columnName : value1 }, { columnName : value2 } ]**
60 |
61 | #### `getDataArray()` :id=method-getDataArray
62 | > Get the 2D array of entries as stored in the sheet
63 |
64 | - ↩️ **Returns** - 2D Array of entries stored in table (all rows except header row)
65 |
66 | #### `getRow(idx)` :id=method-getRow
67 | > Get Object for entry at particular index
68 |
69 | - ↩️ **Returns** - Object for entry at given index **{ col1: value1, col2: value2 }**
70 |
71 | ### Inserting Table Entries
72 |
73 | > The entries of table can be one of the following :- **string, number, boolean, null, undefined**
74 |
75 | Unlike delete and update which requires you to fetch the data atleast once inserting can be done without ever fetching the data from the sheet.
76 | By default, after every insert the table data is fetched again. But passing false for the refetch parameter will skip fetching data. But then it is your responsibilty to call `reload()` before any other operation is performed.
77 |
78 | #### `insertOne(rowValue, refetch)` (async) :id=method-insertOne
79 | > Append one row of data to the table
80 |
81 | Param|Type|Required|Description
82 | ---|---|---|---
83 | `rowValue` _option 1_|Object|✅|Object of entries, keys are based on the columnNames _ex: `{ col1: 'val1', col2: 'val2', ... }`_
84 | `rowValue` _option 2_|Array|✅|Array of entries in order from first column onwards _ex: `['val1', 'val2', ...]`_
85 | `refetch`|boolean|-|If false the updated table is not fetched. _default = true_
86 |
87 | - ✨ **Side effects** - entry is added to the table
88 |
89 | #### `insertMany(rowValueArray, refetch)` (async) :id=method-insertMany
90 | > Append multiple entries to the table at once
91 |
92 | Param|Type|Required|Description
93 | ---|---|---|---
94 | `rowValueArray`|Array.< rowValue >|✅|Array of rows values to append to the table _see [`table.insertOne()`](#method-insertOne) above for more info_
95 | `refetch`|boolean|-|If false the updated table is not fetched. _default = true_
96 |
97 | - ✨ **Side effects** - entries are added to the table
98 |
99 |
100 | #### `insert(data, refetch)` (async) :id=method-insert
101 | > Depending on passed data executes one of `insertOne()` or `insertMany()`
102 |
103 | Param|Type|Required|Description
104 | ---|---|---|---
105 | `data`|rowValue _or_ Array.< rowValue >|✅|Rows value or array of row values to append to the table _see [`table.insertOne()`](#method-insertOne) above for more info_
106 | `refetch`|boolean|-|If false the updated table is not fetched. _default = true_
107 |
108 | - ✨ **Side effects** - entries are added to the table
109 |
110 |
111 | ### Deleting Table Entries
112 |
113 | #### `deleteRow(idx, refetch)` (async) :id=method-deleteRow
114 | > Delete entry at the given index (First entry is 0)
115 |
116 | Param|Type|Required|Description
117 | ---|---|---|---
118 | `idx`|number|✅|index of the entry to delete
119 | `refetch`|boolean|-|If false the updated table is not fetched. _default = true_
120 |
121 | - ✨ **Side effects** - deletes the entry at given index.
122 |
123 | #### `deleteRowRange(startIdx, endIdx, refetch)` (async) :id=method-deleteRowRange
124 | > Delete entry from startIdx to endIdx (excluding endIdx)
125 |
126 | Param|Type|Required|Description
127 | ---|---|---|---
128 | `startIdx`|number|✅|index of beginning of range
129 | `endIdx`|number|✅|index of the end of range (endIdx itself is excluded from range)
130 | `refetch`|boolean|-|If false the updated table is not fetched. _default = true_
131 |
132 | - ✨ **Side effects** - deletes the range of values.
133 |
134 | #### `deleteRows(rows, sorted)` (async) :id=method-deleteRows
135 | > Delete all the entries at the given indices
136 |
137 | Param|Type|Required|Description
138 | ---|---|---|---
139 | `rows`|Array.< number >|✅|current indices of rows to delete
140 | `sorted`|boolean|-|pass true if the `rows` array is already in sorted order. _default = false_
141 |
142 | - ✨ **Side effects** - deletes the values at the given indices and fetches the updated table.
143 |
144 | #### `deleteRowsWhere(selectFunction)` (async) :id=method-deleteRowsWhere
145 | > Delete all the entries that returns true for selectFunction
146 |
147 | The function would be passed the rowValue as dictionary and row index as parameter and should return a boolean value stating whether to delete the row.
148 | ```javascript
149 | // Function to delete rows with index below 5 or having col1 (col1 is name of the column) as 'abc'
150 | const selectionFunction = (rowData, rowIdx) => (rowIdx < 5 || rowData.col1 === 'abc');
151 | await table.deleteRowsWhere(selectionFunction);
152 |
153 | //selection function to select all
154 | const allSelector = () => true;
155 | ```
156 |
157 | Param|Type|Required|Description
158 | ---|---|---|---
159 | `selectFunction`|function|✅|function used to select which rows to delete
160 |
161 | - ✨ **Side effects** - deletes the values and fetches the updated table
162 |
163 |
164 | ### Updating Table Entries
165 | > For updating values in table we can use an **update object** which is an Object keyed by column names with only those entries you need to update.
166 | For example: if we need to update the value of column col2 the udpate object would be `{col2: 'new value'}`
167 |
168 | #### `updateRow(rowIdx, updates)` (async) :id=method-updateRow
169 | > Update row at the given index
170 |
171 | Param|Type|Required|Description
172 | ---|---|---|---
173 | `rowIdx`|number|✅|index of the entry to update
174 | `updates`|Object|✅|Object with the updates to be applied
175 | ''
176 |
177 | - ✨ **Side effects** - updates the row value
178 |
179 | #### `updateRows(rowIndices, updateGenerator)` (async) :id=method-updateRows
180 | > Update row at the given list of indices, using the updates generated from updateGenerator
181 |
182 | ```javascript
183 | // Example generator that can generate the updates given the rowData and rowIdx of the given data
184 | // Here we set the col1 to rowIdx of the row and double the values of col2 for selected rows
185 | const updateGenerator = (rowData, rowIdx) => {return { col1: rowIdx, col2: rowData.col2 * 2 }};
186 | await table.updateRows([0, 2, 4, 8], updateGenerator);
187 | ```
188 |
189 | Param|Type|Required|Description
190 | ---|---|---|---
191 | `rowIndices`|Array.< number >|✅|indices of entries to update
192 | `updateGenerator`|function|✅|function that generates the updates to be applied
193 | ''
194 |
195 | - ✨ **Side effects** - updates the rows values
196 |
197 | #### `updateRowsWhere(selectionFunction)` (async) :id=method-updateRowsWhere
198 | > Update the rows that satisfies a selection condition, using the updates generated from updateGenerator
199 |
200 | Param|Type|Required|Description
201 | ---|---|---|---
202 | `selectionFunction`|function|✅|function used to select which rows to update __see [`table.deleteRowsWhere()`](#method-deleteRowsWhere) above for more info__
203 | `updateGenerator`|function|✅|function that generates the updates to be applied __see [`table.updateRows()`](#method-updateRows) above for more info__
204 |
205 | - ✨ **Side effects** - updates the rows that satisfies the selection criteria
206 |
207 | ### Other methods
208 |
209 | #### `clear()` (async) :id=method-clear
210 | > Clear all the entries from the table
211 |
212 | - ✨ **Side effects** - clears the entire data from table except header rows.
213 |
214 | #### `drop()` (async) :id=method-delete
215 | > Delete the table
216 |
217 | - ✨ **Side effects** - table is deleted and cannot be accessed via table getters in `SheetDatbase`
218 |
219 | #### `reload()` (async) :id=method-delete
220 | > Refetch the data from the google sheet
221 |
222 | - ✨ **Side effects** - Table entries are updated.
223 |
224 | #### `rename(newName)` (async) :id=method-rename
225 | > rename the table
226 |
227 | Param|Type|Required|Description
228 | ---|---|---|---
229 | `newName`|string|✅|new name of the table
230 |
231 | - ✨ **Side effects** - table is no longer accessible via its old name.
232 |
233 | #### `shrinkSheetToFitTable()` (async) :id=method-shrinkSheetToFitTable
234 | > delete unnecessary columns from the Google Sheet
235 |
236 | The way the library works is by fetching and creating an in memory 2D array with all the values in the google sheet. In order to save unnecessary network bandwidth and memory to store data which we know will always be null, it is recommended to call `shrinkSheetToFitTable()` once you have reanmed the column names (which is automatically done by `shrinkTable` param in [`setColumnNames()`](#method-setColumnNames)) or you have manually cleared entries from the Google Sheet Document.
237 |
238 | - ✨ **Side effects** - resizes the sheet to fit only the data which is saved locally. (Make sure the local data is updated with the Google Sheet)
--------------------------------------------------------------------------------
/docs/getting-started/authentication.md:
--------------------------------------------------------------------------------
1 | # Authentication
2 |
3 | In order to access data stored in Google Sheet you need some kind of authentication.
4 |
5 | You have several options for how you want to connect:
6 |
7 | - [API-key](#api-key) - only identifies your application, provides read-only access
8 | - [Service Account](#service-account) - connects as a specific "bot" user generated by google for your application
9 | - [OAuth Client](#oauth) - connect on behalf of a specific user
10 | - [Access Token](#access-token) - connect using the given access token
11 |
12 |
13 | ## Enbale API
14 | **👉 Set up your google project & enable the sheets API 👈**
15 | 1. Go to the [Google Developers Console](https://console.developers.google.com/)
16 | 2. Select your project or create a new one (and then select it)
17 | 3. Enable the Sheets API for your project
18 | - In the sidebar on the left, select **APIs & Services > Library**
19 | - Search for "sheets"
20 | - Click on "Google Sheets API"
21 | - click the blue "Enable" button
22 |
23 | ## Choose one of the Authentication Mode
24 | ### 🔑 API Key :id=api-key
25 | **Read data from public Google Sheets**
26 |
27 | If your project only reads data from a public doc, the simplest and recommended method for authentication is to use an API Key.
28 | It is required by Google so they can at least meter your usage of their API.
29 |
30 | __Setup Instructions__
31 | 1. Follow steps above to set up project and enable sheets API
32 | 2. Create an API key for your project
33 | - In the sidebar on the left, select **Credentials**
34 | - Click on the button "+ CREATE CREDENITALS" and select "API key" option
35 | - Copy the API key
36 | 3. OPTIONAL (limits the API Key only for Sheets API)- click "Restrict key" on popup to set up restrictions
37 | - Click "API restrictions" > Restrict Key"
38 | - Check the "Google Sheets API" checkbox
39 | - Click "Save"
40 |
41 | !> Be careful - never check your API keys / secrets into version control (git)
42 |
43 | ```javascript
44 | const db = new SheetDatabase('');
45 | db.useApiKey(process.env.GOOGLE_API_KEY);
46 | ```
47 |
48 |
49 |
50 | ### 🤖 Service Account (recommended) :id=service-account
51 | **Connect as a bot user that belongs to your app**
52 |
53 | This mode of authentication is helpful if you need to enable application to write data to the database without having any user actually singing in with their Google Account.
54 |
55 | This is a 2-legged oauth method and based on the idea of having "an account that belongs to your application instead of to an individual end user". Use this for an app that needs to access a set of documents that you have full access to, or can at least be shared with your service account. ([read more](https://developers.google.com/identity/protocols/OAuth2ServiceAccount))
56 |
57 | __Setup Instructions__
58 |
59 | 1. Follow steps above to set up project and enable sheets API
60 | 2. Create a service account for your project
61 | - In the sidebar on the left, select **APIs & Services > Credentials**
62 | - Click on the button "+ CREATE CREDENITALS" and select "Service account" option
63 | - Enter name, description, click "CREATE"
64 | - You can skip permissions, click "CONTINUE"
65 | - Click "+ CREATE KEY" button
66 | - Select the "JSON" key type option
67 | - Click "Create" button
68 | - your JSON key file is generated and downloaded to your machine (__it is the only copy!__)
69 | - click "DONE"
70 | - note your service account's email address (also available in the JSON key file)
71 | 3. Share the doc (or docs) with your service account using the email noted above
72 |
73 | !> Be careful - never check your API keys / secrets into version control (git)
74 |
75 | You can now use this file in your project to authenticate as your service account. If you have a config setup involving environment variables, you only need to worry about the `client_email` and `private_key` from the JSON file. For example:
76 |
77 | ```javascript
78 | const creds = require('./config/app-credentials.json'); // the file saved above
79 | const db = new SheetDatabase('');
80 | await db.useServiceAccountAuth(creds);
81 |
82 | // or preferably, loading that info from env vars / config instead of the file
83 | await db.useServiceAccountAuth({
84 | client_email: process.env.GOOGLE_SERVICE_ACCOUNT_EMAIL,
85 | private_key: process.env.GOOGLE_PRIVATE_KEY,
86 | });
87 | ```
88 |
89 |
90 |
91 | ### 👨💻 OAuth Client :id=oauth
92 | **connect on behalf of a user with an Oauth token**
93 |
94 | Use [Google's OAuth2Client](https://github.com/googleapis/google-auth-library-nodejs#oauth2) to authenticate.
95 |
96 | Handling Oauth and how it works is out of scope of this project - but the info you need can be found [here](https://developers.google.com/identity/protocols/oauth2).
97 |
98 | Nevertheless, here is a rough outline of what to do with a few tips:
99 | 1. Follow steps above to set up project and enable sheets API
100 | 2. Create OAuth 2.0 credentials for your project (**these are not the same as the service account credentials described above**)
101 | - Navigate to the [credentials section of the google developer console](https://console.cloud.google.com/apis/credentials)
102 | - Click blue "+ CREATE CREDENITALS" and select "Oauth Client ID" option
103 | - Select your application type and set up authorized domains / callback URIs
104 | - Record your client ID and secret
105 | - You will need to go through an Oauth Consent screen verification process to use these credentials for a production app with many users
106 | 3. For each user you want to connect on behalf of, you must get them to authorize your app which involves asking their permissions by redirecting them to a google-hosted URL
107 | - generate the oauth consent page url and redirect the user to it
108 | - there are many tools, [google provided](https://github.com/googleapis/google-api-nodejs-client#oauth2-client) and [more](https://www.npmjs.com/package/simple-oauth2) [generic](https://www.npmjs.com/package/hellojs) or you can even generate the URL yourself
109 | - make sure you use the credentials generated above
110 | - make sure you include the [appropriate scopes](https://developers.google.com/identity/protocols/oauth2/scopes#sheets) for your application
111 | - the callback URL (if successful) will include a short lived authorization code
112 | - you can then exchange this code for the user's oauth tokens which include:
113 | - an access token (that expires) which can be used to make API requests on behalf of the user, limited to the scopes requested and approved
114 | - a refresh token (that does not expire) which can be used to generate new access tokens
115 | - save these tokens somewhere secure like a database (ideally you should encrypt these before saving!)
116 | 4. Initialize an OAuth2Client with your apps oauth credentials and the user's tokens, and pass the client to your GoogleSpreadsheet object
117 |
118 |
119 | ```javascript
120 | const { OAuth2Client } = require('google-auth-library');
121 |
122 | // Initialize the OAuth2Client with your app's oauth credentials
123 | const oauthClient = new OAuth2Client({
124 | clientId: process.env.GOOGLE_OAUTH_CLIENT_ID,
125 | clientSecret: process.env.GOOGLE_OAUTH_CLIENT_SECRET
126 | });
127 |
128 | // Pre-configure the client with credentials you have stored in e.g. your database
129 | // NOTE - refresh_token is required, whilt the access token and expiryDate are optional
130 | // (the refresh token is used to generate a missing/expired access token)
131 | const { accessToken, refreshToken, expiryDate } = await fetchUserGoogleCredsFromDatabase();
132 | oauthClient.credentials.access_token = accessToken;
133 | oauthClient.credentials.refresh_token = refreshToken;
134 | oauthClient.credentials.expiry_date = expiryDate; // Unix epoch milliseconds
135 |
136 | // Listen in whenever a new access token is obtained. You might want to store them in your database.
137 | // Mind that the refresh_token never changes (unless it's revoked, in which case your end-user will
138 | // need to go through the full authentication flow again), so storing the new access_token is optional.
139 | oauthClient.on('tokens', credentials => {
140 | console.log(credentials.access_token);
141 | console.log(credentials.scope);
142 | console.log(credentials.expiry_date);
143 | console.log(credentials.token_type); // will always be 'Bearer'
144 | })
145 |
146 | const db = new SheetDatabase('');
147 | db.useOAuth2Client(oauthClient);
148 | ```
149 |
150 |
151 | ### 👨💻 Access Token :id=access-token
152 | **connect on behalf of a user with an Oauth token**
153 |
154 | If you want to handle the generation and refreshing of OAuth token yourself you can simply provide the raw access token which will be used for authentication.
155 |
156 | !> This assumes you are creating and managing/refreshing the token yourself. Once the token expires, it is your responsibility to provide the new access token.
157 |
158 | ```javascript
159 | // Get the token for OAuth
160 | const access_token = handleOAuth();
161 |
162 | const db = new SheetDatabase('');
163 | db.useAccessToken(access_token);
164 | ```
165 |
--------------------------------------------------------------------------------
/docs/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | sheets-database
6 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
31 |
32 |
33 |
34 |
35 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "sheets-database",
3 | "version": "1.0.4",
4 | "description": "Library to help use a Google Sheet as a database",
5 | "main": "lib/src/index.js",
6 | "types": "lib/src/index.d.ts",
7 | "scripts": {
8 | "build": "rm -rf lib/ && tsc",
9 | "start": "npm run build && node lib/src/index.js",
10 | "test": "npm run build && jest --runInBand",
11 | "jest": "jest --runInBand",
12 | "lintjs": "eslint './lib/src/**/*.{js,ts,tsx}' --quiet --fix",
13 | "prepublishOnly": "npm run jest",
14 | "version": "git add -A src",
15 | "postversion": "git push && git push --tags"
16 | },
17 | "repository": {
18 | "type": "git",
19 | "url": "git+https://github.com/rahul-jha98/sheets-database.git"
20 | },
21 | "keywords": [
22 | "google",
23 | "sheets",
24 | "database",
25 | "db",
26 | "google sheets",
27 | "spreadsheets",
28 | "spreadsheet"
29 | ],
30 | "author": "Rahul Jha ",
31 | "license": "MIT",
32 | "bugs": {
33 | "url": "https://github.com/rahul-jha98/sheets-database/issues"
34 | },
35 | "homepage": "https://rahul-jha98.github.io/sheets-database/",
36 | "files": [
37 | "lib/src/"
38 | ],
39 | "jest": {
40 | "testMatch": [
41 | "**/__tests__/**/*.js?(x)",
42 | "**/?(*.)+(spec|test).js?(x)"
43 | ],
44 | "modulePathIgnorePatterns": [
45 | "node_modules/"
46 | ],
47 | "testTimeout": 15000
48 | },
49 | "dependencies": {
50 | "axios": "^0.21.1",
51 | "google-auth-library": "^6.1.3"
52 | },
53 | "devDependencies": {
54 | "@types/jest": "^26.0.19",
55 | "@typescript-eslint/eslint-plugin": "^4.11.1",
56 | "@typescript-eslint/parser": "^4.11.1",
57 | "eslint": "^7.16.0",
58 | "eslint-config-google": "^0.14.0",
59 | "eslint-plugin-jsdoc": "^30.7.9",
60 | "jest": "^26.6.3",
61 | "ts-jest": "^26.4.4",
62 | "typescript": "^4.1.3"
63 | }
64 | }
65 |
--------------------------------------------------------------------------------
/src/dbhelper/Database.ts:
--------------------------------------------------------------------------------
1 | import axios, {
2 | AxiosError, AxiosInstance, AxiosRequestConfig,
3 | } from 'axios';
4 | import {JWT} from 'google-auth-library';
5 | import {ACTIONS} from './actions';
6 | import {Sheet} from './ResponseStructure';
7 | import {Table} from './Table';
8 | import type {OAuth2Client} from 'google-auth-library';
9 | import {checkIfNameValid} from './utils';
10 |
11 | const AUTH_MODE = {
12 | ACCESS_TOKEN: 1,
13 | API_KEY: 2,
14 | SERVICE_ACCOUNT: 3,
15 | OAUTH: 4,
16 | };
17 |
18 | const GOOGLE_AUTH_SCOPES = [
19 | 'https://www.googleapis.com/auth/spreadsheets',
20 | ];
21 |
22 | const sheet_fields = 'sheets.data.rowData.values.effectiveValue,sheets.properties';
23 | const document_fields = 'properties.title';
24 | /**
25 | * @class
26 | */
27 | export class Database {
28 | /**
29 | * @description
30 | * sheetId of the connected sheet document
31 | */
32 | sheetId: string;
33 | /**
34 | * @description
35 | * title of the sheet document
36 | */
37 | title?: string;
38 |
39 | /**
40 | * @private
41 | * @description
42 | * object with tables connected by sheetId
43 | */
44 | _tables:{[sheetId: number]: Table} = {};
45 | /**
46 | * @description
47 | * axios instance to make requests
48 | */
49 | axios: AxiosInstance;
50 | authMode?: number;
51 | apiKey?: string;
52 | accessToken?: string;
53 | jwtClient?: JWT;
54 | oAuth2Client?: OAuth2Client;
55 |
56 | notifyAction: (actionType: number, ...params: string[]) => void = () => {};
57 |
58 | /**
59 | * @param {string} [sheetId] sheetId of the excel sheet to connect
60 | */
61 | constructor(sheetId = '') {
62 | this.sheetId = sheetId;
63 | this.axios = axios.create({
64 | baseURL: `https://sheets.googleapis.com/v4/spreadsheets/${sheetId}`,
65 |
66 | paramsSerializer(params) {
67 | let options = '';
68 |
69 | Object.keys(params).forEach((key: string) => {
70 | const isParamTypeObject = typeof params[key] === 'object';
71 |
72 | const isParamTypeArray = isParamTypeObject && params[key].length >= 0;
73 |
74 | if (!isParamTypeObject) {
75 | options += `${key}=${encodeURIComponent(params[key])}&`;
76 | }
77 |
78 | if (isParamTypeObject && isParamTypeArray) {
79 | params[key].forEach((val: string) => {
80 | options += `${key}=${encodeURIComponent(val)}&`;
81 | });
82 | }
83 | });
84 | return options ? options.slice(0, -1) : options;
85 | },
86 | });
87 | this.axios.interceptors.request.use(
88 | this._setAuthorizationInRequest.bind(this));
89 |
90 | this.axios.interceptors.response.use(
91 | (response) => response,
92 | this._onAxiosError.bind(this),
93 | );
94 | }
95 |
96 | /**
97 | * @param {function} onActionCallback callback when action happens
98 | */
99 | subscrible(onActionCallback: (actionType: number, ...params: string[]) => void) {
100 | this.notifyAction = onActionCallback;
101 | }
102 |
103 | /**
104 | * @private
105 | * @param {Object} _ sheet data returned by Google API
106 | */
107 | _updateOrCreateTable({properties, data}: Sheet) {
108 | const {sheetId} = properties;
109 | if (!this._tables[sheetId]) {
110 | this._tables[sheetId] = new Table(this, {properties, data});
111 | } else {
112 | this._tables[sheetId]._properties = properties;
113 | this._tables[sheetId]._fillTableData(data);
114 | }
115 | }
116 |
117 | /**
118 | * Make a request to fetch all the table with its data
119 | * @param {boolean} withData should request sheet data
120 | */
121 | async loadData(withData = true) {
122 | const response = await this.axios.get('/', {
123 | params: {
124 | includeGridData: withData,
125 | fields: `${document_fields},${sheet_fields}`,
126 | },
127 | });
128 | this.title = response.data.properties.title;
129 | response.data.sheets.forEach((s: Sheet) => {
130 | this._updateOrCreateTable(s);
131 | });
132 | }
133 |
134 | // AUTH RELATED METHODS ///////////////////////////////
135 | /**
136 | * Set the api key to use when making requests
137 | * @param {string} key Api Key to use
138 | */
139 | useApiKey(key: string) {
140 | this.authMode = AUTH_MODE.API_KEY;
141 | this.apiKey = key;
142 | }
143 |
144 | /**
145 | * Set the token to use when making requests
146 | * @param {string} token Auth Token to use
147 | */
148 | useAccessToken(token: string) {
149 | this.authMode = AUTH_MODE.ACCESS_TOKEN;
150 | this.accessToken = token;
151 | }
152 |
153 | async useServiceAccount(email: string, privateKey: string) {
154 | this.jwtClient = new JWT({
155 | email: email,
156 | key: privateKey,
157 | scopes: GOOGLE_AUTH_SCOPES,
158 | });
159 | await this.jwtClient.authorize();
160 | this.authMode = AUTH_MODE.SERVICE_ACCOUNT;
161 | }
162 |
163 | useOAuth2Client(oAuth2Client: OAuth2Client) {
164 | this.authMode = AUTH_MODE.OAUTH;
165 | this.oAuth2Client = oAuth2Client;
166 | }
167 |
168 | async _setAuthorizationInRequest(
169 | config: AxiosRequestConfig,
170 | ): Promise {
171 | if (this.authMode === AUTH_MODE.ACCESS_TOKEN) {
172 | if (!this.accessToken) throw new Error('Access Token not provided');
173 | config.headers.Authorization = `Bearer ${this.accessToken}`;
174 | } else if (this.authMode === AUTH_MODE.API_KEY) {
175 | if (!this.apiKey) throw new Error('Please set API key');
176 | config.params = config.params || {};
177 | config.params.key = this.apiKey;
178 | } else if (this.authMode === AUTH_MODE.SERVICE_ACCOUNT) {
179 | if (!this.jwtClient) throw new Error('JWT Auth not set up properly');
180 | await this.jwtClient.authorize();
181 | config.headers.Authorization = `Bearer ${this.jwtClient.credentials.access_token}`;
182 | } else if (this.authMode === AUTH_MODE.OAUTH) {
183 | if (!this.oAuth2Client) throw new Error('OAuth Client was not set up properly');
184 | const credentials = await this.oAuth2Client.getAccessToken();
185 | config.headers.Authorization = `Bearer ${credentials.token}`;
186 | } else {
187 | throw new Error('You need to set up some kind of authorization');
188 | }
189 | return config;
190 | }
191 |
192 | async _onAxiosError(error: AxiosError) {
193 | if (error.response && error.response.data) {
194 | // usually the error has a code and message, but occasionally not
195 | if (!error.response.data.error) throw error;
196 |
197 | const {code, message} = error.response.data.error;
198 | error.message = `Google API error - [${code}] ${message}`;
199 | throw error;
200 | }
201 |
202 | if (error?.response?.status === 403) {
203 | if (this.authMode === AUTH_MODE.API_KEY) {
204 | throw new Error('Sheet is private. Use authentication or make public.');
205 | }
206 | }
207 | throw error;
208 | }
209 |
210 |
211 | async _requestUpdate(
212 | requestType: string,
213 | requestParams: any,
214 | fetchSpreadsheet = false) {
215 | const response = await this.axios.post(':batchUpdate', {
216 | requests: [{[requestType]: requestParams}],
217 | includeSpreadsheetInResponse: fetchSpreadsheet,
218 | responseIncludeGridData: fetchSpreadsheet,
219 | });
220 |
221 | if (fetchSpreadsheet) {
222 | response.data.updatedSpreadsheet.sheets.forEach((s: Sheet) =>
223 | this._updateOrCreateTable(s),
224 | );
225 | }
226 |
227 | return response.data.replies[0][requestType];
228 | }
229 |
230 | async _requestBatchUpdate(requests: any) {
231 | await this.axios.post(':batchUpdate', {
232 | requests,
233 | });
234 | }
235 |
236 |
237 | get tables() {
238 | return this._tables;
239 | }
240 |
241 | /**
242 | * Adds a table with the given properties
243 | * @param {Object} properties the properties of the new sheet configuration
244 | */
245 | async addTable(properties: Record) : Promise
{
246 | // https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets/request#AddSheetRequest
247 | if (!checkIfNameValid(properties.title)) {
248 | throw new Error(`Table names can only consist of letters, numbers and underscores`);
249 | }
250 | const headerValues = properties['headerValues'];
251 | this._validateColumnNames(headerValues);
252 |
253 | delete properties['headerValues'];
254 |
255 | const response = await this._requestUpdate('addSheet', {
256 | properties: properties || {},
257 | }, true);
258 |
259 | const newSheetId = response.properties.sheetId;
260 | const newSheet = this._tables[newSheetId];
261 |
262 | // allow it to work with `.headers` but `.headerValues` is the real prop
263 | if (headerValues) {
264 | await newSheet.setColumnNames(headerValues, true);
265 | }
266 |
267 | return newSheet;
268 | }
269 |
270 | /**
271 | * Deletes the table with the given sheetId
272 | * @param {number} sheetId sheetId of the sheet to delete
273 | */
274 | async deleteTable(sheetId: number) {
275 | // https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets/request#DeleteSheetRequest
276 | const tableName = this._tables[sheetId].name;
277 | await this._requestUpdate('deleteSheet', {sheetId});
278 | delete this._tables[sheetId];
279 | this.notifyAction(ACTIONS.TABLE_DELETED, tableName);
280 | }
281 |
282 | /**
283 | * Updates the properties of the sheet with given sheetId
284 | * @param {number} sheetId sheetId of the worksheet
285 | * @param {Object} properties Properties that needs to be updated
286 | */
287 | async updateSheetProperties(sheetId: number, properties: Object) {
288 | if ('title' in properties) {
289 | if (!checkIfNameValid(properties['title'])) {
290 | throw new Error(`Table names can only consist of letters, numbers and underscores`);
291 | }
292 | }
293 | const tableName = this._tables[sheetId].name;
294 | await this._requestUpdate('updateSheetProperties', {
295 | properties: {
296 | sheetId: sheetId,
297 | ...properties,
298 | },
299 | fields: Object.keys(properties).join(','),
300 | },
301 | true);
302 | if ('title' in properties) {
303 | this.notifyAction(ACTIONS.TABLE_RENAMED, tableName, properties['title']);
304 | }
305 | }
306 |
307 | /**
308 | * Loads the cell data with the given filters
309 | * @param {string|Array.} filters filters used for the request
310 | */
311 | async loadCells(filters: string | Array) {
312 | const readOnlyMode = this.authMode === AUTH_MODE.API_KEY;
313 | const filtersArray = Array.isArray(filters) ? filters : [filters];
314 |
315 | const dataFilters = filtersArray.map((filter) => {
316 | return readOnlyMode ? filter : {a1Range: filter};
317 | });
318 |
319 | let result;
320 | if (this.authMode === AUTH_MODE.API_KEY) {
321 | result = await this.axios.get('/', {
322 | params: {
323 | includeGridData: true,
324 | ranges: dataFilters,
325 | fields: sheet_fields,
326 | },
327 | });
328 | } else {
329 | result = await this.axios.post(':getByDataFilter', {
330 | includeGridData: true,
331 | dataFilters,
332 | },
333 | {
334 | params: {
335 | fields: sheet_fields,
336 | },
337 | });
338 | }
339 | const {sheets} = result.data;
340 | sheets.forEach((s: Sheet) => this._updateOrCreateTable(s));
341 | }
342 |
343 | _validateColumnNames(headerValues: string []) {
344 | if (!headerValues) throw new Error('Column names is empty');
345 |
346 | if (!checkIfNameValid(headerValues)) {
347 | throw new Error('Invalid column names. Column names should contain of only letters, numbers and underscore');
348 | }
349 |
350 | if (new Set(headerValues).size !== headerValues.length) {
351 | throw new Error('There are duplicate column names');
352 | }
353 | if (!headerValues.filter(Boolean).length) {
354 | throw new Error('All header values are blank');
355 | }
356 | }
357 | }
358 |
--------------------------------------------------------------------------------
/src/dbhelper/ResponseStructure.ts:
--------------------------------------------------------------------------------
1 | export type SheetProperties = {
2 | sheetId: number,
3 | title: string,
4 | index: number,
5 | [key: string]: any
6 | }
7 | export type SheetData = {
8 | rowData: Array<{values: Array<{effectiveValue: {
9 | numberValue?: number,
10 | stringValue?: string,
11 | boolValue?: boolean,
12 | }}>}>
13 | }
14 | export type Sheet = {
15 | properties: SheetProperties,
16 | data: Array
17 | }
18 |
--------------------------------------------------------------------------------
/src/dbhelper/Table.ts:
--------------------------------------------------------------------------------
1 | import {Sheet, SheetData, SheetProperties} from './ResponseStructure';
2 | import {columnNumberToName, reduceRowsToDelete} from './utils';
3 |
4 | type primitiveTypes = string | boolean | number | null | undefined;
5 |
6 | import type {Database} from './Database';
7 |
8 | export class Table {
9 | /**
10 | * @private
11 | * @description
12 | * Reference of the belonging Database (Google Sheet)
13 | */
14 | _database: Database;
15 | /**
16 | * @private
17 | * @description
18 | *
19 | */
20 | _properties: SheetProperties;
21 | /**
22 | * @private
23 | * @description
24 | * all the values of the sheet stored in 2D Array
25 | */
26 | _cells: (primitiveTypes)[][];
27 | /**
28 | * @private
29 | * @description
30 | * holds the index of the last row with values
31 | */
32 | _lastRowsWithValues = 0;
33 |
34 | /**
35 | * @description
36 | * Names of columns in the table
37 | * - Values in the first row of the sheet
38 | */
39 | columnNames: string[];
40 |
41 | /**
42 | * @description
43 | * Is there any change made locally which has made the current data invalid
44 | * - Note - the value may be incorrect if there are remote change on the sheet
45 | */
46 | isFetchPending = true;
47 |
48 | constructor(database: Database, {properties, data}: Sheet) {
49 | this._database = database;
50 | this._properties = properties;
51 |
52 | this._cells = [];
53 | this.columnNames = [];
54 |
55 | this._fillTableData(data);
56 | }
57 |
58 | /**
59 | * Loads the column names of the table
60 | * @param enforceHeaders whether to raise error if headers empty
61 | */
62 | async loadColumnNames(enforceHeaders = true) {
63 | const rows = await this._getCellsInRange(`A1:${this.lastColumnLetter}1`);
64 | if (!rows) {
65 | if (!enforceHeaders) {
66 | return;
67 | }
68 | throw new Error('Table Headers (Header Row) is missing.');
69 | }
70 | const columnNames = rows[0].map((header: string) => header.trim());
71 | try {
72 | this._database._validateColumnNames(columnNames);
73 | this.columnNames = columnNames;
74 | } catch (err) {
75 | if (err.message === 'All header values are blank' && !enforceHeaders) {
76 | this.columnNames = columnNames;
77 | }
78 | throw err;
79 | }
80 | }
81 |
82 |
83 | /**
84 | * Set the header values in the sheet to the given values
85 | * @param headerValues Name of header values to be set
86 | * @param {boolean} [shrinkTable=false] pass true if you want to shrink table:
87 | * - Note - It deletes the values beyond length of headers passed
88 | */
89 | async setColumnNames(headerValues: string[], shrinkTable = false) {
90 | this._database._validateColumnNames(headerValues);
91 | return this._setFirstRow(headerValues, shrinkTable);
92 | }
93 |
94 | /**
95 | * refetch the data of the table
96 | */
97 | async reload() {
98 | await this._database.loadCells(this._a1SheetName);
99 | this.isFetchPending = false;
100 | }
101 |
102 | /**
103 | * rename the table to the given name
104 | * @param newName New Name of the table
105 | */
106 | async rename(newName: string) {
107 | return this._database.updateSheetProperties(this.sheetId, {title: newName});
108 | }
109 |
110 | /**
111 | * Delete the given table
112 | */
113 | async drop() {
114 | await this._database.deleteTable(this.sheetId);
115 | }
116 |
117 | /**
118 | * get the data of the table in the form of 2D array
119 | * - Note: The data is the one which we got in last reload
120 | * @return {Array>} the data values aray
121 | */
122 | getDataArray() : primitiveTypes[][] {
123 | return this._cells.slice(1, this._lastRowsWithValues + 1);
124 | }
125 |
126 | /**
127 | * get the data of the table in the form of array of objects
128 | * @return array of entries as object keyed by column names
129 | */
130 | getData() : Array> {
131 | const dataArray = [];
132 | for (let i = 1; i <= this._lastRowsWithValues
133 | ; i++) {
134 | const rowObject:Record = {};
135 |
136 | for (let j = 0; j < this.columnNames.length; j++) {
137 | rowObject[this.columnNames[j]] = this._cells[i][j];
138 | }
139 | dataArray.push(rowObject);
140 | }
141 | return dataArray;
142 | }
143 |
144 | /**
145 | * get the entry at particular index
146 | * @param idx index of the entry needed
147 | * @returns entry with given index in form of object keyed by column names
148 | */
149 | getRow(idx: number) : Record {
150 | idx = idx + 1;
151 | this._ensureRowValid(idx);
152 | const rowObject:Record = {};
153 |
154 | for (let j = 0; j < this.columnNames.length; j++) {
155 | rowObject[this.columnNames[j]] = this._cells[idx][j];
156 | }
157 | return rowObject;
158 | }
159 |
160 | /**
161 | * insert data (single entry or array of entries) to the table
162 | * @param data data to be inserted
163 | * @param {boolean} [refetch=true] whether to refetch rows after operation
164 | */
165 | async insert(
166 | data: primitiveTypes[]|Record|Array>,
167 | refetch = true,
168 | ) {
169 | if (Array.isArray(data)) {
170 | if (Array.isArray(data[0]) ||
171 | (typeof data[0] === 'object' && data[0] != null)) {
172 | return this.insertMany(data as Array>, refetch);
173 | }
174 | }
175 | return this.insertMany([data as Array|Record], refetch);
176 | }
177 |
178 | /**
179 | * add the single entry to the table
180 | * @param rowValue single entry to be inserted in table
181 | * @param refetch whether to refetch rows after the operation
182 | */
183 | async insertOne(rowValue: Array|Record, refetch = true) {
184 | return this.insertMany([rowValue], refetch=refetch);
185 | }
186 |
187 | /**
188 | * add an array of entries to the table
189 | * @param rowValueArray array of entries to be inserted in table
190 | * @param refetch whether to refetch rows after the operation
191 | */
192 | async insertMany(
193 | rowValueArray: Array>,
194 | refetch = true,
195 | ) {
196 | const rowsArray: primitiveTypes[][] = [];
197 |
198 | rowValueArray.forEach((row) => {
199 | let rowAsArray;
200 |
201 | if (Array.isArray(row)) {
202 | rowAsArray = row;
203 | } else if (typeof row === 'object' && row != null) {
204 | rowAsArray = [];
205 | for (let i = 0; i < this.columnNames.length; i++) {
206 | const columnName = this.columnNames[i];
207 | rowAsArray[i] = row[columnName];
208 | }
209 | } else {
210 | throw new Error('Row must be object or array');
211 | }
212 | rowsArray.push(rowAsArray);
213 | });
214 |
215 | return this._addRows(rowsArray, refetch = refetch);
216 | }
217 |
218 | /**
219 | * Deletes the row with the given index
220 | * - Note all the subsequent rows are moved up in the sheet
221 | * @param idx index of row to delete
222 | * @param refetch whether to refetch rows after the operation
223 | */
224 | async deleteRow(idx: number, refetch = true) {
225 | return this.deleteRowRange(idx, idx+1, refetch);
226 | }
227 |
228 | /**
229 | * deletes
230 | * @param startIndex starting index of range of rows
231 | * @param endIndex end index of range of rows
232 | * @param refetch whether to refetch rows after the operation
233 | */
234 | async deleteRowRange(startIndex: number, endIndex: number, refetch = true) {
235 | if (startIndex > endIndex) {
236 | throw new Error('startIndex needs to be less than endIndex');
237 | }
238 | startIndex = startIndex + 1;
239 | this._ensureRowValid(startIndex);
240 | this._ensureRowValid(endIndex);
241 |
242 | await this._database._requestUpdate('deleteRange', {
243 | range: {
244 | sheetId: this.sheetId,
245 | startRowIndex: startIndex,
246 | endRowIndex: endIndex+1,
247 | },
248 | shiftDimension: 'ROWS',
249 | });
250 |
251 | if (refetch) {
252 | await this.reload();
253 | } else {
254 | this.isFetchPending = true;
255 | }
256 | }
257 |
258 | /**
259 | * given the current indices delete all the rows from the table
260 | * @param {number[]} rows current indices of of all the rows to delete
261 | * @param {boolean} sorted whether the rows is in sorted format.
262 | * - This prevents sort to be explicitly called
263 | */
264 | async deleteRows(rows: number[], sorted = false) {
265 | if (rows.length === 0) {
266 | return;
267 | }
268 | if (!sorted) {
269 | rows.sort((a, b) => a - b);
270 | }
271 | this._ensureRowValid(rows[0] + 1);
272 | this._ensureRowValid(rows[rows.length - 1] + 1);
273 | const rowRanges = reduceRowsToDelete(rows);
274 | const requests = [];
275 | for (const range of rowRanges) {
276 | requests.push({'deleteRange': {
277 | range: {
278 | sheetId: this.sheetId,
279 | startRowIndex: range[0] + 1,
280 | endRowIndex: range[1] + 1,
281 | },
282 | shiftDimension: 'ROWS',
283 | }});
284 | }
285 | await this._database._requestBatchUpdate(requests);
286 | await this.reload();
287 | }
288 |
289 | /**
290 | * Delete rows which match the given criteria
291 | * @param selectFunction condition which will be used to select which rows to delete
292 | */
293 | async deleteRowsWhere(selectFunction: (rowData: Record,
294 | rowIdx: number) => boolean) {
295 | const rowsToDelete : number[] = [];
296 | const isToBeDeleted = this.getData().map(selectFunction);
297 | isToBeDeleted.forEach((status, rowIdx) => {
298 | if (status) {
299 | rowsToDelete.push(rowIdx);
300 | }
301 | });
302 | return this.deleteRows(rowsToDelete);
303 | }
304 |
305 | /**
306 | * applies update to the row at given index.
307 | * @param rowIdx index of row to update
308 | * @param updates updates that you wish to apply,
309 | * If passing object only put those keys that needs to be updated
310 | * @param refetch whether to refetch rows after the operation
311 | */
312 | async updateRow(rowIdx: number, updates: Array|Record, refetch = true) {
313 | let updatedRow: primitiveTypes[] = [];
314 | if (Array.isArray(updates)) {
315 | updatedRow = updates;
316 | } else {
317 | this.columnNames.forEach((name, idx) => {
318 | if (updates.hasOwnProperty(name)) {
319 | updatedRow[idx] = updates[name];
320 | } else {
321 | updatedRow[idx] = null;
322 | }
323 | });
324 | }
325 | const endColumn = columnNumberToName(this.columnNames.length);
326 | const rowRange = `${this._encodedA1SheetName}!A${rowIdx+2}:${endColumn}${rowIdx+2}`;
327 |
328 | await this._database.axios.request({
329 | method: 'POST',
330 | url: `values:batchUpdate`,
331 | data: {
332 | includeValuesInResponse: true,
333 | valueInputOption: 'RAW',
334 | data: [{
335 | range: rowRange,
336 | majorDimension: 'ROWS',
337 | values: [updatedRow],
338 | }],
339 | },
340 | });
341 | if (refetch) {
342 | return this.reload();
343 | }
344 | }
345 |
346 | /**
347 | * applies updates to all the rows in the array passed
348 | * @param rowIndices array of row values to be updated
349 | * @param updateGenerator function that generates the updates for each row
350 | * the function will recieve the data of the row and its row index as parameter
351 | * and is expected to return an object with the key as column name whose value needs to be updated
352 | * @param refetch whether to refetch rows after the operation
353 | */
354 | async updateRows(
355 | rowIndices: number[],
356 | updateGenerator : (data?: Record, idx?: number) => Record,
357 | refetch = true,
358 | ) {
359 | const endColumn = columnNumberToName(this.columnNames.length);
360 | const encodedA1SheetName = this._encodedA1SheetName;
361 | const columnNames = this.columnNames;
362 |
363 | const updates = rowIndices.map((rowIdx) => updateGenerator(this.getRow(rowIdx), rowIdx));
364 |
365 | const data = updates.map((update, updateIdx) => {
366 | const idx = rowIndices[updateIdx];
367 | const rowRange = `${encodedA1SheetName}!A${idx+2}:${endColumn}${idx+2}`;
368 | const updatedRow = columnNames.map((name) => {
369 | if (update.hasOwnProperty(name)) {
370 | return update[name];
371 | } else {
372 | return null;
373 | }
374 | });
375 | return {
376 | range: rowRange,
377 | majorDimension: 'ROWS',
378 | values: [updatedRow],
379 | };
380 | });
381 |
382 | await this._database.axios.request({
383 | method: 'POST',
384 | url: `values:batchUpdate`,
385 | data: {
386 | includeValuesInResponse: true,
387 | valueInputOption: 'RAW',
388 | data,
389 | },
390 | });
391 | if (refetch) {
392 | return this.reload();
393 | }
394 | }
395 |
396 | /**
397 | * applies update to rows that satisfy the given condition
398 | * @param selectionCondition function that is used to select the rows where updates will be applied
399 | * the function will receive the data of the row and its row index as parameter
400 | * and should return a boolean value showing whether to select the row or not.
401 | * @param updateGenerator function that generates the updates for each row
402 | * the function will recieve the data of the row and its row index as parameter
403 | * and is expected to return an object with the key as column name whose value needs to be updated
404 | * @param refetch whether to refetch rows after the operation
405 | */
406 | async updateRowsWhere(
407 | selectionCondition: (rowData?: Record, index?: number) => boolean,
408 | updateGenerator: (data?: Record, idx?: number) => Record,
409 | refetch = true,
410 | ) {
411 | const rowsToDelete : number[] = [];
412 | const isToBeDeleted = this.getData().map(selectionCondition);
413 | isToBeDeleted.forEach((status, rowIdx) => {
414 | if (status) {
415 | rowsToDelete.push(rowIdx);
416 | }
417 | });
418 | return this.updateRows(rowsToDelete, updateGenerator, refetch);
419 | }
420 | /**
421 | * clears all the entries from the table
422 | * @param {boolean} [refetch=true] whether to refetch the values once operation completes
423 | */
424 | async clear(refetch = true) {
425 | await this._database.axios.request({
426 | method: 'post',
427 | url: `/values/${this._encodedA1SheetName}!A2:${this.lastColumnLetter+this.rowCount}:clear`,
428 | });
429 | if (refetch) {
430 | await this.reload();
431 | } else {
432 | this.isFetchPending = true;
433 | }
434 | }
435 |
436 | /**
437 | * Resizes the sheet to contatin only data which is stored locally.
438 | */
439 | async shrinkSheetToFitTable() {
440 | return this._database.updateSheetProperties(this.sheetId, {
441 | gridProperties: {
442 | rowCount: this._lastRowsWithValues + 2,
443 | columnCount: this.columnNames.length,
444 | },
445 | });
446 | }
447 |
448 | // PROPERTY GETTERS \\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\
449 | /**
450 | * Array of data objects with column names as keys
451 | */
452 | get data() : Array> {
453 | return this.getData();
454 | }
455 |
456 | /**
457 | * sheetId of the given table
458 | */
459 | get sheetId() : number {
460 | return this._getProperty('sheetId');
461 | }
462 |
463 | /**
464 | * name of the given table
465 | */
466 | get title() : string {
467 | return this._getProperty('title');
468 | }
469 |
470 | /**
471 | * index of the table (worksheet) in google sheet document
472 | */
473 | get index() : number {
474 | return this._getProperty('index');
475 | }
476 | /**
477 | * name of the given table
478 | */
479 | get name() : string {
480 | return this.title;
481 | }
482 |
483 | /**
484 | * Number of entreis in the table
485 | */
486 | get length(): number {
487 | return this._lastRowsWithValues - 1;
488 | }
489 |
490 | /**
491 | * Properites of the table grid
492 | */
493 | get _gridProperties() {
494 | return this._getProperty('gridProperties');
495 | }
496 |
497 | /**
498 | * nubmer of rows in grid
499 | */
500 | get rowCount() : number {
501 | return this._gridProperties.rowCount;
502 | }
503 |
504 | /**
505 | * number of columns in grid
506 | */
507 | get columnCount() : number {
508 | return this._gridProperties.columnCount;
509 | }
510 |
511 | /**
512 | * name of the given sheet
513 | */
514 | get _a1SheetName() : string {
515 | return `'${this.title.replace(/'/g, '\'\'')}'`;
516 | }
517 | /**
518 | * sheet name to be passed as params in API calls
519 | */
520 | get _encodedA1SheetName() : string {
521 | return encodeURIComponent(this._a1SheetName);
522 | }
523 |
524 | /**
525 | * Column letter of the last column in grid
526 | */
527 | get lastColumnLetter() : string {
528 | return columnNumberToName(this.columnCount);
529 | }
530 |
531 | // HELPER PRIVATE FUNCTIONS \\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\
532 | _getProperty(propertyName: string) {
533 | return this._properties[propertyName];
534 | }
535 |
536 | /**
537 | * @private
538 | * @param a1Range Range in the form of A1 representation eg: A1:D1
539 | * @param options prameters along with data
540 | */
541 | async _getCellsInRange(a1Range: string) {
542 | const response = await this._database.axios.get(
543 | `/values/${this._encodedA1SheetName}!${a1Range}`);
544 | return response.data.values;
545 | }
546 |
547 | /**
548 | * @private
549 | * resize the number of columns
550 | * @param columnCount new value of column count
551 | */
552 | async _setColumnSize(columnCount: number) {
553 | const gridProperties = this._gridProperties;
554 | gridProperties.rowCount = this.rowCount;
555 | gridProperties.columnCount = columnCount;
556 | return this._database.updateSheetProperties(this.sheetId, {
557 | gridProperties,
558 | });
559 | }
560 |
561 | _fillTableData(dataRanges: Array|null|undefined) {
562 | if (!dataRanges) {
563 | this.isFetchPending = true;
564 | return;
565 | }
566 | this.isFetchPending = false;
567 | dataRanges.forEach((range: SheetData) => {
568 | const numRows = this.rowCount;
569 | const numColumns = this.columnCount;
570 |
571 | for (let row = 0; row < numRows; row++) {
572 | for (let column = 0; column < numColumns; column++) {
573 | if (!this._cells[row]) this._cells[row] = [];
574 | if (!this._cells[row][column]) this._cells[row][column] = undefined;
575 | if (
576 | range.rowData &&
577 | range.rowData[row] &&
578 | range.rowData[row].values[column]
579 | ) {
580 | this._lastRowsWithValues =
581 | row;
582 | const cellValue = range.rowData[row].values[column].effectiveValue;
583 | let value = undefined;
584 | if (cellValue) {
585 | if (cellValue.numberValue !== undefined) {
586 | value = cellValue.numberValue;
587 | } else if (cellValue.stringValue !== undefined) {
588 | value = cellValue.stringValue;
589 | } else if (cellValue.boolValue !== undefined) {
590 | value = cellValue.boolValue;
591 | }
592 | }
593 | this._cells[row][column] = value;
594 | }
595 | }
596 | }
597 | });
598 | }
599 |
600 | async _addRows(rowsArrays: primitiveTypes[][],
601 | refetch = true,
602 | insert = false) {
603 | if (!Array.isArray(rowsArrays)) throw new Error('Row values needs to be an array');
604 |
605 | if (!this.columnNames) await this.loadColumnNames();
606 |
607 | await this._database.axios.request({
608 | method: 'post',
609 | url: `/values/${this._encodedA1SheetName}!A1:append`,
610 | params: {
611 | valueInputOption: 'RAW',
612 | insertDataOption: insert ? 'INSERT_ROWS' : 'OVERWRITE',
613 | },
614 | data: {
615 | values: rowsArrays,
616 | },
617 | });
618 |
619 | // if new rows were added, we need update sheet.rowRount
620 | if (refetch) {
621 | await this.reload();
622 | }
623 | }
624 |
625 | async _setFirstRow(headerValues: string [], shrinkTable = false) {
626 | if (headerValues.length > this.columnCount) {
627 | await this._setColumnSize(headerValues.length);
628 | }
629 | const response = await this._database.axios.request({
630 | method: 'put',
631 | url: `/values/${this._encodedA1SheetName}!1:1`,
632 | params: {
633 | valueInputOption: 'USER_ENTERED', // other option is RAW
634 | includeValuesInResponse: true,
635 | },
636 | data: {
637 | range: `${this._a1SheetName}!1:1`,
638 | majorDimension: 'ROWS',
639 | values: [
640 | [
641 | ...headerValues,
642 | // pad the rest of the row with empty values to clear them all out
643 | ...Array(this.columnCount - headerValues.length).fill(''),
644 | ],
645 | ],
646 | },
647 | });
648 | this.columnNames = response.data.updatedData.values[0];
649 | if (shrinkTable && headerValues.length < this.columnCount) {
650 | return this.shrinkSheetToFitTable();
651 | } else {
652 | for (let i = 0; i < headerValues.length; i++) {
653 | this._cells[0][i] = headerValues[i];
654 | }
655 | }
656 | }
657 |
658 | /**
659 | * @private
660 | * Helper function to ensure that the row index is valid before making request
661 | * @param idx actual index of the row we need to access
662 | */
663 | _ensureRowValid(idx: number) {
664 | if (idx > this._lastRowsWithValues || idx < 1) {
665 | throw new Error(`Cannot operate on row index ${idx-1} from table with ${
666 | this._lastRowsWithValues} entries`);
667 | }
668 | }
669 | }
670 |
--------------------------------------------------------------------------------
/src/dbhelper/actions.ts:
--------------------------------------------------------------------------------
1 | export const ACTIONS = {
2 | TABLE_DELETED: 1,
3 | TABLE_RENAMED: 2,
4 | };
5 |
--------------------------------------------------------------------------------
/src/dbhelper/utils.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Returns the column name for the gviven column number
3 | * @param {number} columnNumber 1 indexed position of the column
4 | * @return {string} column name of the given column
5 | */
6 | export function columnNumberToName(columnNumber: number): string {
7 | let temp;
8 | let name = '';
9 | let column = columnNumber;
10 |
11 | while (column > 0) {
12 | temp = (column - 1) % 26;
13 | name = String.fromCharCode(temp + 65) + name;
14 | column = (column - temp - 1) / 26;
15 | }
16 |
17 | return name;
18 | }
19 |
20 | /**
21 | * Returns the column number for the given column name
22 | * @param {string} columnName alphabetical name of the column
23 | * @return {number} column number of the given column
24 | */
25 | export function columnNameToNumber(columnName: string): number {
26 | let column = 0;
27 | const length = columnName.length;
28 | for (let i = 0; i < length; i++) {
29 | column += (columnName.charCodeAt(i) - 64) * 26 ** (length - i - 1);
30 | }
31 | return column;
32 | }
33 |
34 | export function reduceRowsToDelete(rowsToDelete: number[]) : number[][] {
35 | const rangeRowsToDelete = rowsToDelete.map((rowNo) => [rowNo, rowNo+1]);
36 | const reducedRowsToDelete : number[][] = [];
37 | reducedRowsToDelete.push(rangeRowsToDelete[0]);
38 | let lastIdx = 0;
39 |
40 | for (let i = 1; i < rangeRowsToDelete.length; i++) {
41 | if (reducedRowsToDelete[lastIdx][1] === rangeRowsToDelete[i][0]) {
42 | reducedRowsToDelete[lastIdx][1] = rangeRowsToDelete[i][1];
43 | } else {
44 | reducedRowsToDelete.push(rangeRowsToDelete[i]);
45 | lastIdx++;
46 | }
47 | }
48 |
49 | let deletedSoFar = 0;
50 | for (let i = 0; i < reducedRowsToDelete.length; i++) {
51 | const [a, b] = reducedRowsToDelete[i];
52 | const toDelete = b-a;
53 | reducedRowsToDelete[i]= [a-deletedSoFar, b-deletedSoFar];
54 | deletedSoFar += toDelete;
55 | }
56 | return reducedRowsToDelete;
57 | }
58 |
59 | export function checkIfNameValid(data: string[] | string) : boolean {
60 | if (typeof(data) === 'string') {
61 | data = [data];
62 | }
63 | const validRegex = /^[A-Za-z0-9_]+$/;
64 | return data.every((name) => name.match(validRegex) !== null);
65 | }
66 |
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
1 | import {Database} from './dbhelper/Database';
2 | import {ACTIONS} from './dbhelper/actions';
3 | import {Table} from './dbhelper/Table';
4 | import type {OAuth2Client} from 'google-auth-library';
5 | /**
6 | * @class
7 | */
8 | export class SheetDatabase {
9 | [key: string]: any;
10 | _db: Database;
11 |
12 | _tables: Record;
13 |
14 | /**
15 | * @param {string} [sheetId] sheetId for the Google Sheets file to be used
16 | */
17 | constructor(sheetId = '') {
18 | this._db = new Database(sheetId);
19 | this._tables = {};
20 |
21 | this._db.subscrible((actionType: number, ...payload: string[]) => {
22 | if (actionType === ACTIONS.TABLE_DELETED) {
23 | this._removeTableReference(payload[0]);
24 | } else if (actionType === ACTIONS.TABLE_RENAMED) {
25 | const table = this.getTable(payload[0]);
26 | this._removeTableReference(payload[0]);
27 | this._addTableReference(payload[1], table);
28 | }
29 | });
30 | }
31 |
32 |
33 | /**
34 | * fetches the tables (worksheets) from the connected sheet
35 | * @param {boolean} [syncTableEntries=true] whether the table data should also be loaded
36 | */
37 | async sync(syncTableEntries = true) {
38 | await this._db.loadData(syncTableEntries);
39 |
40 | for (const table of Object.values(this._db.tables)) {
41 | await table.loadColumnNames(false);
42 | this._addTableReference(table.name, table);
43 | }
44 | }
45 |
46 | /**
47 | * Returns the table with the given table name
48 | * @param {string} tableName Name of the table you need
49 | * @return {Table} Table object corresponding to given table name
50 | */
51 | getTable(tableName: string): Table {
52 | if (this._tables.hasOwnProperty(tableName)) {
53 | return this._tables[tableName];
54 | }
55 | throw new Error('No table named ' + tableName);
56 | }
57 |
58 | /**
59 | * Add a table to the database
60 | * @param {string} tableName name of the table to add
61 | * @param {Array.} columnNames list with all the column names
62 | * @param {number} [rowCount=20] initial number or rows
63 | * @return {Promise