├── .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 | 4 | 5 | [![NPM version](https://img.shields.io/npm/v/sheets-database)](https://www.npmjs.com/package/sheets-database) 6 | [![License](https://img.shields.io/badge/license-MIT-green)](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 | 5 | 6 | [![NPM version](https://img.shields.io/npm/v/sheets-database)](https://www.npmjs.com/package/sheets-database) 7 | [![License](https://img.shields.io/badge/license-MIT-green)](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 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /docs/assets/imgs/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 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
} A promise to return the added table object 64 | */ 65 | async addTable(tableName: string, columnNames: string[], rowCount = 20) { 66 | const table = await this._db.addTable({ 67 | title: tableName, 68 | gridProperties: { 69 | rowCount: rowCount, 70 | columnCount: columnNames.length, 71 | frozenRowCount: 1, 72 | }, 73 | headerValues: columnNames, 74 | }); 75 | this._addTableReference(tableName, table); 76 | return table; 77 | } 78 | 79 | /** 80 | * Deletes the table with the given table name 81 | * @param {string} tableName name of the table to drop 82 | */ 83 | async dropTable(tableName: string) { 84 | await this.getTable(tableName).drop(); 85 | } 86 | 87 | /** 88 | * Change the name of a table 89 | * @param {string} tableName name of the table to be renamed 90 | * @param {string} newTableName new name of the table 91 | */ 92 | async renameTable(tableName: string, newTableName: string) { 93 | await this.getTable(tableName).rename(newTableName); 94 | } 95 | 96 | /** 97 | * title of the Sheet Document 98 | */ 99 | get title() : string { 100 | if (this._db.title) { 101 | return this._db.title; 102 | } 103 | throw new Error('Must call sync() once before accessing title'); 104 | } 105 | 106 | /** 107 | * array of tables sorted by their order in the Google Sheet 108 | */ 109 | get tablesByIndex() : Table[] { 110 | return [...Object.values(this._tables)] 111 | .sort((tab1, tab2) => tab1.index - tab2.index); 112 | } 113 | 114 | /** 115 | * object of tables indexed by their table name 116 | */ 117 | get tables() : {[tableName : string]: Table} { 118 | return this._tables; 119 | } 120 | 121 | // AUTH RELATED FUNCTIONS \\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\ 122 | /** 123 | * Set authentication mode to use given API key 124 | * @param {string} key API key with Sheets API enabled 125 | */ 126 | useApiKey(key: string) { 127 | this._db.useApiKey(key); 128 | } 129 | 130 | /** 131 | * Set authentication mode to use google OAuth 132 | * Note: The token needs to be refreshed externally 133 | * @param {string} token User access token with suitable authscope 134 | */ 135 | useAccessToken(token: string) { 136 | this._db.useAccessToken(token); 137 | } 138 | 139 | /** 140 | * Set authentication mode to use service account with the given credentials. 141 | * @param credentials object which will contain keys client_email and private_key. 142 | */ 143 | async useServiceAccount(credentials: {client_email: string, private_key: string}) { 144 | return this._db.useServiceAccount(credentials.client_email, credentials.private_key); 145 | } 146 | 147 | /** 148 | * Use the oauthclient passed for authentication. Token is refreshed used the refresh token automatically. 149 | * @param oAuth2Client client object with refresh token set. The access token and expiration date can be generated 150 | */ 151 | useOAuth2Client(oAuth2Client: OAuth2Client) { 152 | this._db.useOAuth2Client(oAuth2Client); 153 | } 154 | 155 | /** 156 | * @private 157 | * @param name key which will be used to access table 158 | * @param table corresponding Table object 159 | */ 160 | _addTableReference(name: string, table: Table) { 161 | if (!this.hasOwnProperty(name) && typeof this[name] !== 'function') { 162 | this[name] = table; 163 | } 164 | this._tables[name] = table; 165 | } 166 | 167 | /** 168 | * @private 169 | * @param name key of property which will be removed 170 | */ 171 | _removeTableReference(name: string) { 172 | if (this.hasOwnProperty(name) && this[name] instanceof Table) { 173 | delete this[name]; 174 | } 175 | delete this._tables[name]; 176 | } 177 | } 178 | 179 | export default SheetDatabase; 180 | -------------------------------------------------------------------------------- /test/auth.test.ts: -------------------------------------------------------------------------------- 1 | // @ts-ignore 2 | import {load_database} from './load_database'; 3 | // @ts-ignore 4 | import API_KEY from './API_KEY'; 5 | // @ts-ignore 6 | import creds from './creds.json'; 7 | 8 | import {JWT} from 'google-auth-library'; 9 | import type {SheetDatabase} from '../src'; 10 | import type {Table} from '../src/dbhelper/Table'; 11 | 12 | const databases: Record = load_database(); 13 | const GOOGLE_AUTH_SCOPES = [ 14 | 'https://www.googleapis.com/auth/spreadsheets', 15 | ]; 16 | 17 | 18 | const checkDatabaseOperations= (SHEET_ACCESS: string, options: Record) => { 19 | const database = databases[SHEET_ACCESS]; 20 | let table: Table; 21 | describe(`For sheet which is ${SHEET_ACCESS}`, () => { 22 | if (options.canDoReadOperation) { 23 | it('reading database properties should succeed', async () => { 24 | await database.sync(false); 25 | expect(database.title).toBeTruthy(); 26 | table = database.tablesByIndex[0]; 27 | }); 28 | 29 | it('reading table data should succeed', async () => { 30 | await table.reload(); 31 | expect(table.isFetchPending).toBeFalsy(); 32 | }); 33 | } else { 34 | it('reading database properties should fail', async () => { 35 | await expect(database.sync(false)).rejects.toThrow(options.readError as string); 36 | }); 37 | } 38 | 39 | if (options.canDoWriteOperation) { 40 | it('writing to table should succeed', async () => { 41 | if (!table) throw Error('Read needs to pass to check write'); 42 | table.setColumnNames(['header1', 'header2', 'header3']); 43 | }); 44 | } else { 45 | it('writing to table should fail', async () => { 46 | if (!table) return; 47 | await expect(table. 48 | setColumnNames(['header1', 'header2', 'header3'])) 49 | .rejects.toThrow(options.writeError as string); 50 | }); 51 | } 52 | }); 53 | }; 54 | 55 | describe('Authorization', () => { 56 | describe('Accessing without auth', () => { 57 | it('fetchTables should fail for all databases', async () => { 58 | await expect(databases.PUBLIC.sync()).rejects.toThrow( 59 | `You need to set up some kind of authorization`, 60 | ); 61 | }); 62 | }); 63 | 64 | 65 | describe('Accessing with API Key', () => { 66 | it('*setting all database to use API Key', async () => { 67 | await databases.PRIVATE.useApiKey(API_KEY); 68 | await databases.PUBLIC.useApiKey(API_KEY); 69 | await databases.PUBLIC_READ_ONLY.useApiKey(API_KEY); 70 | }); 71 | 72 | checkDatabaseOperations('PUBLIC', { 73 | canDoReadOperation: true, 74 | canDoWriteOperation: false, 75 | writeError: '[401]', 76 | }); 77 | 78 | checkDatabaseOperations('PUBLIC_READ_ONLY', { 79 | canDoReadOperation: true, 80 | canDoWriteOperation: false, 81 | writeError: '[401]', 82 | }); 83 | 84 | checkDatabaseOperations('PRIVATE', { 85 | canDoReadOperation: false, 86 | canDoWriteOperation: false, 87 | readError: '[403]', 88 | }); 89 | }); 90 | 91 | 92 | describe('Accessing with Authorization Token', () => { 93 | let token = ''; 94 | const jwtClient = new JWT({ 95 | email: creds.client_email, 96 | key: creds.private_key, 97 | scopes: GOOGLE_AUTH_SCOPES, 98 | }); 99 | 100 | it('*initialize with access token', async () => { 101 | await jwtClient.authorize(); 102 | token = jwtClient.credentials.access_token as string; 103 | databases.PUBLIC.useAccessToken(token); 104 | databases.PRIVATE.useAccessToken(token); 105 | databases.PUBLIC_READ_ONLY.useAccessToken(token); 106 | databases.PRIVATE_READ_ONLY.useAccessToken(token); 107 | }); 108 | 109 | checkDatabaseOperations('PUBLIC', { 110 | canDoWriteOperation: true, 111 | canDoReadOperation: true, 112 | }); 113 | 114 | checkDatabaseOperations('PUBLIC_READ_ONLY', { 115 | canDoWriteOperation: false, 116 | canDoReadOperation: true, 117 | writeError: '[403]', 118 | }); 119 | 120 | checkDatabaseOperations('PRIVATE', { 121 | canDoReadOperation: true, 122 | canDoWriteOperation: true, 123 | }); 124 | 125 | checkDatabaseOperations('PRIVATE_READ_ONLY', { 126 | canDoWriteOperation: false, 127 | canDoReadOperation: true, 128 | writeError: '[403]', 129 | }); 130 | }); 131 | 132 | 133 | describe('Accessing with invalid key and token', () => { 134 | it('*initialize incorrect Access Token', () => { 135 | databases.PUBLIC.useAccessToken('random_text_123'); 136 | }); 137 | checkDatabaseOperations('PUBLIC', { 138 | canDoWriteOperation: false, 139 | canDoReadOperation: false, 140 | readError: '[401]', 141 | }); 142 | 143 | it('*initialize incorrect API Key', () => { 144 | databases.PUBLIC.useApiKey('random_text_123'); 145 | }); 146 | checkDatabaseOperations('PUBLIC', { 147 | canDoWriteOperation: false, 148 | canDoReadOperation: false, 149 | readError: '[400]', 150 | }); 151 | }); 152 | }); 153 | -------------------------------------------------------------------------------- /test/entries.test.ts: -------------------------------------------------------------------------------- 1 | import {SheetDatabase} from '../src/index'; 2 | import {Table} from '../src/dbhelper/Table'; 3 | // @ts-ignore 4 | import creds from './creds.json'; 5 | import {JWT} from 'google-auth-library'; 6 | 7 | // @ts-ignore 8 | import {load_database} from './load_database'; 9 | 10 | const databases: Record = load_database(); 11 | const GOOGLE_AUTH_SCOPES = [ 12 | 'https://www.googleapis.com/auth/spreadsheets', 13 | ]; 14 | 15 | const database = databases.PRIVATE; 16 | 17 | let table : Table; 18 | const tableName = `tableEntries${new Date().getTime()}`; 19 | 20 | const INITIAL_COLUMN_NAMES = ['index', 'letter', 'rowNo']; 21 | 22 | const INITIAL_DATA = [1, 2, 3, 4, 5, 6].map((number) => [number-1, `row${number-1}`, number]); 23 | 24 | describe('Handle CRUD Operations on Table Entries', () => { 25 | beforeAll(async () => { 26 | const jwtClient = new JWT({ 27 | email: creds.client_email, 28 | key: creds.private_key, 29 | scopes: GOOGLE_AUTH_SCOPES, 30 | }); 31 | 32 | await jwtClient.authorize(); 33 | database.useAccessToken(jwtClient.credentials.access_token as string); 34 | table = await database.addTable(tableName, INITIAL_COLUMN_NAMES); 35 | await database[tableName].insert(INITIAL_DATA); 36 | }); 37 | 38 | afterAll(async () => { 39 | await table.drop(); 40 | }); 41 | 42 | 43 | describe('refetching table entries', () => { 44 | it('can refetch mulitple rows', async () => { 45 | await table.reload(); 46 | expect(table.getDataArray().length).toBe(INITIAL_DATA.length); 47 | }); 48 | 49 | it('can be accessed using column names as keys', () => { 50 | expect(table.getData()[0].index).toEqual(INITIAL_DATA[0][0]); 51 | expect(table.getData()[0].letter).toEqual(INITIAL_DATA[0][1]); 52 | }); 53 | }); 54 | 55 | describe('inserting values to table', () => { 56 | it('can insert row based on array', async () => { 57 | const dataCount = table.getDataArray().length; 58 | const data = [dataCount, `letter${dataCount}`, dataCount + 1]; 59 | const expectedIndex = INITIAL_DATA.length; 60 | await table.insert(data); 61 | expect(table.getData()[expectedIndex].index).toEqual(data[0]); 62 | }); 63 | 64 | it('can insert row based on object', async () => { 65 | const newData = {index: 100, rowNo: 101, letter: 'testData'}; 66 | await table.insert(newData); 67 | expect(table.getData()[table.getData().length-1].letter).toBe(newData.letter); 68 | }); 69 | 70 | it('can insert multiple rows at once', async () => { 71 | const data = [{index: 500, rowNo: 500, letter: 'testObject'}, [501, 'testArray', 501]]; 72 | await table.insert(data); 73 | // @ts-ignore 74 | expect(table.getData()[table.getData().length-1].letter).toBe(data[1][1]); 75 | }); 76 | }); 77 | 78 | describe('deleting entries from table', () => { 79 | it('can delete single row', async () => { 80 | const rowNo = table.getDataArray().length - 2; 81 | await table.deleteRow(rowNo); 82 | expect(table.getDataArray().length).toBe(rowNo+1); 83 | }); 84 | 85 | it('can delete row range', async () => { 86 | expect(table.getDataArray()[0][0]).toBe(INITIAL_DATA[0][0]); 87 | await table.deleteRowRange(0, 3); 88 | expect(table.getDataArray()[0][0]).toBe(INITIAL_DATA[3][0]); 89 | }); 90 | 91 | it('can clear all entries', async () => { 92 | await table.clear(); 93 | expect(table.getDataArray().length).toBe(0); 94 | }); 95 | it('can delete based on condition', async () => { 96 | await table.insert(INITIAL_DATA); 97 | expect(table.getDataArray().length).toBe(INITIAL_DATA.length); 98 | await table.deleteRowsWhere((data) => data.index as number % 2 === 0); 99 | expect(table.getDataArray().length).toBe(INITIAL_DATA.length/2); 100 | }); 101 | it('can handle scenario when no item meets condition', async () => { 102 | await table.deleteRowsWhere((data) => data.index as number > 100); 103 | }); 104 | }); 105 | 106 | describe('updating entries', () => { 107 | it('can update rows using array', async () => { 108 | await table.updateRow(0, [1, null]); 109 | expect(table.getRow(0).index).toBe(1); 110 | }); 111 | it('can update rows using objects', async () => { 112 | await table.updateRow(0, {letter: 'changed'}); 113 | expect(table.getRow(0).letter).toBe('changed'); 114 | }); 115 | it('can update multiple rows', async () => { 116 | await table.updateRows([0, 1, 2], (data) => { 117 | return {letter: data?.index}; 118 | }); 119 | }); 120 | }); 121 | }); 122 | -------------------------------------------------------------------------------- /test/load_database.ts: -------------------------------------------------------------------------------- 1 | import {SheetDatabase} from '../src/index'; 2 | 3 | export const load_database = () => ({ 4 | PUBLIC: new SheetDatabase('1hVV4i7tFpzKjD3lqEcMIDrQBs-IR2w9hhJBVuvybGhw'), 5 | PUBLIC_READ_ONLY: new SheetDatabase('1fn1UE49_LPwVZ3BEkluhGA1lD5ods0EJTls9Jk5oVRA'), 6 | PRIVATE: new SheetDatabase('12XVgXTiwwriBAyDu8yNK5fPt0vTVNGOv-nJtBcElQJ8'), 7 | PRIVATE_READ_ONLY: new SheetDatabase('1ciy4TyVsMobTNPvxmYXT6bnNNQjffNTnmj3E9vHCKJg') 8 | }); 9 | -------------------------------------------------------------------------------- /test/names.test.ts: -------------------------------------------------------------------------------- 1 | import {SheetDatabase} from '../src/index'; 2 | import {Table} from '../src/dbhelper/Table'; 3 | // @ts-ignore 4 | import {load_database} from './load_database'; 5 | import creds from './creds.json'; 6 | 7 | const databases: Record = load_database(); 8 | const database = databases.PRIVATE; 9 | 10 | describe('Prevent incorrect naming of tables and columns', () => { 11 | beforeAll(async () => { 12 | await database.useServiceAccount(creds); 13 | }); 14 | 15 | describe('adding table with name as one of the class properties', () => { 16 | it('can add new table with name one of the properties', async () => { 17 | const table = await database.addTable('addTable', ['header1', 'header2', 'header3']); 18 | expect(table.title).toBe('addTable'); 19 | // expect(database['addTable'].name).toThrow(); 20 | expect(database.tables.addTable.title).toBe('addTable'); 21 | 22 | await database.addTable('test', ['abc']); 23 | await database.test.drop(); 24 | await database.getTable('addTable').drop(); 25 | }); 26 | it('can prevent setting incorrect table headers', async () => { 27 | const table = await database.addTable('headers', ['header1', 'header2', 'header3']); 28 | expect(async () => await table.setColumnNames(['header@1', 'header2'])).rejects; 29 | }); 30 | 31 | afterAll(async () => { 32 | await database.headers.drop(); 33 | }); 34 | }); 35 | }); 36 | -------------------------------------------------------------------------------- /test/tables.test.ts: -------------------------------------------------------------------------------- 1 | import {SheetDatabase} from '../src/index'; 2 | import {Table} from '../src/dbhelper/Table'; 3 | // @ts-ignore 4 | import creds from './creds.json'; 5 | import {JWT} from 'google-auth-library'; 6 | 7 | // @ts-ignore 8 | import {load_database} from './load_database'; 9 | 10 | 11 | const databases: Record = load_database(); 12 | const GOOGLE_AUTH_SCOPES = [ 13 | 'https://www.googleapis.com/auth/spreadsheets', 14 | ]; 15 | 16 | const database = databases.PRIVATE; 17 | 18 | describe('Handle CRUD Operations on Tables', () => { 19 | beforeAll(async () => { 20 | const jwtClient = new JWT({ 21 | email: creds.client_email, 22 | key: creds.private_key, 23 | scopes: GOOGLE_AUTH_SCOPES, 24 | }); 25 | 26 | await jwtClient.authorize(); 27 | database.useAccessToken(jwtClient.credentials.access_token as string); 28 | }); 29 | 30 | it('can fetch Tables Data', async () => { 31 | await database.sync(); 32 | }); 33 | describe('adding tables and modify its properties', () => { 34 | const date = new Date(); 35 | const tableName = `addedSheet${date.getTime()}`; 36 | let table: Table; 37 | 38 | afterAll(async () => { 39 | if (table) await table.drop(); 40 | }); 41 | it('can add new table', async () => { 42 | const numTables = database.tablesByIndex.length; 43 | table = await database.addTable(tableName, ['header1', 'header2', 'header3']); 44 | expect(database.tablesByIndex.length).toBe(numTables + 1); 45 | expect(table.title).toBe(tableName); 46 | }); 47 | it('throws error when table name or column names has spaces or other special characters', async () => { 48 | expect(async () => await database.addTable(`addedSheet ${date.getTime()}`, ['header1', 'header2', 'header3'])).rejects; 49 | expect(async () => await database.addTable(`addedSheet${date.getTime()}`, ['header 1', 'header2', 'header3'])).rejects; 50 | }); 51 | it('is having header rows', async () => { 52 | await table.loadColumnNames(); 53 | expect(table.columnNames.length === 3); 54 | expect(table.columnNames[0]).toBe('header1'); 55 | expect(table.columnNames[2]).toBe('header3'); 56 | }); 57 | }); 58 | 59 | describe('updating table properties', () => { 60 | it('can rename tables', async () => { 61 | await database.sync(); 62 | const numTables = database.tablesByIndex.length; 63 | const oldTableName = `toRename${new Date().getTime()}`; 64 | await database.addTable(oldTableName, ['temp', 'data']); 65 | expect(database.tablesByIndex.length).toBe(numTables + 1); 66 | const newTableName = `toRename${new Date().getTime()}`; 67 | await database.renameTable(oldTableName, newTableName); 68 | expect(() => database.getTable(oldTableName)).toThrow(); 69 | expect(database.tables[newTableName]).toBeInstanceOf(Table); 70 | await database.dropTable(newTableName); 71 | }); 72 | 73 | it('can resize when renaming column names', async () => { 74 | await database.sync(); 75 | const numTables = database.tablesByIndex.length; 76 | const tableName = `toRenameHeaders${new Date().getTime()}`; 77 | await database.addTable(tableName, ['temp', 'data']); 78 | expect(database.tablesByIndex.length).toBe(numTables + 1); 79 | 80 | await database.getTable(tableName).setColumnNames(['temp', 'data', 'newcolumn']); 81 | await database[tableName].drop(); 82 | }); 83 | }); 84 | 85 | describe('deleting tables', () => { 86 | it('can drop tables', async () => { 87 | await database.sync(); 88 | const numTables = database.tablesByIndex.length; 89 | const tableName = `toDelete${new Date().getTime()}`; 90 | await database.addTable(tableName, ['temp', 'data']); 91 | expect(database.tablesByIndex.length).toBe(numTables + 1); 92 | 93 | await database.dropTable(tableName); 94 | expect(database.tablesByIndex.length).toBe(numTables); 95 | }); 96 | }); 97 | }); 98 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "lib", 4 | 5 | "target": "es2018", 6 | "module": "commonjs", 7 | "declaration": true, 8 | 9 | "pretty": true, 10 | 11 | "strict": true, 12 | "skipLibCheck": true, 13 | "allowUnreachableCode": false, 14 | "allowUnusedLabels": false, 15 | "noEmitOnError": true, 16 | "noFallthroughCasesInSwitch": true, 17 | "noImplicitReturns": true, 18 | "forceConsistentCasingInFileNames": true, 19 | "resolveJsonModule": true, 20 | "esModuleInterop": true 21 | }, 22 | "include": ["src/*", "test/*"], 23 | "exclude": ["node_modules", "lib"] 24 | } 25 | --------------------------------------------------------------------------------