diff --git a/.github/workflows/full_test.yml b/.github/workflows/full_test.yml
new file mode 100644
index 0000000..2b0744e
--- /dev/null
+++ b/.github/workflows/full_test.yml
@@ -0,0 +1,66 @@
+name: Full Test
+on:
+ pull_request:
+ types: [opened, synchronize]
+
+env:
+ INTEGRATION_TEST_SPREADSHEET_ID: ${{ secrets.INTEGRATION_TEST_SPREADSHEET_ID }}
+ INTEGRATION_TEST_AUTH_JSON: ${{ secrets.INTEGRATION_TEST_AUTH_JSON }}
+
+jobs:
+ full_test:
+ runs-on: ubuntu-latest
+ strategy:
+ matrix:
+ node-version: ['22.x'] # Can expand if needed
+
+ if: github.event.review.state == 'approved' || github.event.pull_request.user.login == 'edocsss'
+
+ steps:
+ - uses: actions/checkout@v3
+ with:
+ ref: ${{ github.head_ref }}
+
+ - name: Dump GitHub context
+ env:
+ GITHUB_CONTEXT: ${{ toJson(github) }}
+ run: echo "$GITHUB_CONTEXT"
+
+ - name: Setup Node.js ${{ matrix.node-version }}
+ uses: actions/setup-node@v3
+ with:
+ node-version: ${{ matrix.node-version }}
+ cache: 'npm'
+
+ - name: Install dependencies
+ run: npm ci
+
+ - name: Compile TypeScript
+ run: npx tsc --noEmit
+
+ - name: Run Unit Tests with Coverage
+ run: |
+ npm run test -- --coverage
+ cp coverage/lcov.info coverage.out
+
+ - name: Generate Coverage Badge
+ uses: tj-actions/coverage-badge-js@v1
+ with:
+ green: 80
+ coverage-summary-path: coverage/coverage-summary.json
+
+ - name: Add Coverage Badge
+ uses: stefanzweifel/git-auto-commit-action@v4
+ id: auto-commit-action
+ with:
+ commit_message: Apply Code Coverage Badge
+ skip_fetch: true
+ skip_checkout: true
+ file_pattern: ./README.md
+
+ - name: Push Changes
+ if: steps.auto-commit-action.outputs.changes_detected == 'true'
+ uses: ad-m/github-push-action@master
+ with:
+ github_token: ${{ github.token }}
+ branch: ${{ github.head_ref }}
\ No newline at end of file
diff --git a/.github/workflows/unit_test.yml b/.github/workflows/unit_test.yml
new file mode 100644
index 0000000..a4281a6
--- /dev/null
+++ b/.github/workflows/unit_test.yml
@@ -0,0 +1,27 @@
+name: Unit Test
+on: push
+
+jobs:
+ unit_test:
+ runs-on: ubuntu-latest
+ strategy:
+ matrix:
+ node-version: ['18.x', '20.x', '22.x']
+
+ steps:
+ - uses: actions/checkout@v3
+
+ - name: Setup Node.js ${{ matrix.node-version }}
+ uses: actions/setup-node@v3
+ with:
+ node-version: ${{ matrix.node-version }}
+ cache: 'npm'
+
+ - name: Install dependencies
+ run: npm ci
+
+ - name: TypeScript compile check (optional)
+ run: npx tsc --noEmit
+
+ - name: Run unit tests
+ run: npm test
\ No newline at end of file
diff --git a/README.md b/README.md
index 09ad6ad..75a732b 100644
--- a/README.md
+++ b/README.md
@@ -1 +1,413 @@
-# JSFreeDB
\ No newline at end of file
+# GoFreeDB
+
+
+
+
+
+
+
+
Ship Faster with Google Sheets as a Database!
+
+
+
+ JSFreeDB is a JavaScript library that provides common and simple database abstractions on top of Google Sheets.
+
+
+
+
+
+
+ 
+ 
+ 
+
+
+
+## Features
+
+1. Provide a straightforward **key-value** and **row based database** interfaces on top of Google Sheets.
+2. Serve your data **without any server setup** (by leveraging Google Sheets infrastructure).
+3. Support **flexible enough query language** to perform various data queries.
+4. **Manually manipulate data** via the familiar Google Sheets UI (no admin page required).
+
+> For more details, please read [our analysis](https://github.com/FreeLeh/docs/blob/main/freedb/alternatives.md#why-should-you-choose-freedb)
+> on other alternatives and how it compares with `FreeDB`.
+
+## Table of Contents
+
+* [Protocols](#protocols)
+* [Getting Started](#getting-started)
+ * [Installation](#installation)
+ * [Pre-requisites](#pre-requisites)
+* [Row Store](#row-store)
+ * [Querying Rows](#querying-rows)
+ * [Counting Rows](#counting-rows)
+ * [Inserting Rows](#inserting-rows)
+ * [Updating Rows](#updating-rows)
+ * [Deleting Rows](#deleting-rows)
+ * [Struct Field to Column Mapping](#struct-field-to-column-mapping)
+* [KV Store](#kv-store)
+ * [Get Value](#get-value)
+ * [Set Key](#set-key)
+ * [Delete Key](#delete-key)
+ * [Supported Modes](#supported-modes)
+* [KV Store V2](#kv-store-v2)
+ * [Get Value](#get-value-v2)
+ * [Set Key](#set-key-v2)
+ * [Delete Key](#delete-key-v2)
+ * [Supported Modes](#supported-modes-v2)
+
+## Protocols
+
+Clients are strongly encouraged to read through the **[protocols document](https://github.com/FreeLeh/docs/blob/main/freedb/protocols.md)** to see how things work
+under the hood and **the limitations**.
+
+## Getting Started
+
+### Installation
+
+```
+go get github.com/FreeLeh/GoFreeDB
+```
+
+### Pre-requisites
+
+1. Obtain a Google [OAuth2](https://github.com/FreeLeh/docs/blob/main/google/authentication.md#oauth2-flow) or [Service Account](https://github.com/FreeLeh/docs/blob/main/google/authentication.md#service-account-flow) credentials.
+2. Prepare a Google Sheets spreadsheet where the data will be stored.
+
+## Row Store
+
+Let's assume each row in the table is represented by the `Person` struct.
+
+```go
+type Person struct {
+ Name string `db:"name"`
+ Age int `db:"age"`
+}
+```
+
+Please read the [struct field to column mapping](#struct-field-to-column-mapping) section
+to understand the purpose of the `db` struct field tag.
+
+```go
+import (
+ "github.com/FreeLeh/GoFreeDB"
+ "github.com/FreeLeh/GoFreeDB/google/auth"
+)
+
+// If using Google Service Account.
+auth, err := auth.NewServiceFromFile(
+ "",
+ freedb.FreeDBGoogleAuthScopes,
+ auth.ServiceConfig{},
+)
+
+// If using Google OAuth2 Flow.
+auth, err := auth.NewOAuth2FromFile(
+ "",
+ "",
+ freedb.FreeDBGoogleAuthScopes,
+ auth.OAuth2Config{},
+)
+
+store := freedb.NewGoogleSheetRowStore(
+ auth,
+ "",
+ "",
+ freedb.GoogleSheetRowStoreConfig{Columns: []string{"name", "age"}},
+)
+defer store.Close(context.Background())
+```
+
+### Querying Rows
+
+```go
+// Output variable
+var output []Person
+
+// Select all columns for all rows
+err := store.
+ Select(&output).
+ Exec(context.Background())
+
+// Select a few columns for all rows (non-selected struct fields will have default value)
+err := store.
+ Select(&output, "name").
+ Exec(context.Background())
+
+// Select rows with conditions
+err := store.
+ Select(&output).
+ Where("name = ? OR age >= ?", "freedb", 10).
+ Exec(context.Background())
+
+// Select rows with sorting/order by
+ordering := []freedb.ColumnOrderBy{
+ {Column: "name", OrderBy: freedb.OrderByAsc},
+ {Column: "age", OrderBy: freedb.OrderByDesc},
+}
+err := store.
+ Select(&output).
+ OrderBy(ordering).
+ Exec(context.Background())
+
+// Select rows with offset and limit
+err := store.
+ Select(&output).
+ Offset(10).
+ Limit(20).
+ Exec(context.Background())
+```
+
+### Counting Rows
+
+```go
+// Count all rows
+count, err := store.
+ Count().
+ Exec(context.Background())
+
+// Count rows with conditions
+count, err := store.
+ Count().
+ Where("name = ? OR age >= ?", "freedb", 10).
+ Exec(context.Background())
+```
+
+### Inserting Rows
+
+```go
+err := store.Insert(
+ Person{Name: "no_pointer", Age: 10},
+ &Person{Name: "with_pointer", Age: 20},
+).Exec(context.Background())
+```
+
+### Updating Rows
+
+```go
+colToUpdate := make(map[string]interface{})
+colToUpdate["name"] = "new_name"
+colToUpdate["age"] = 12
+
+// Update all rows
+err := store.
+ Update(colToUpdate).
+ Exec(context.Background())
+
+// Update rows with conditions
+err := store.
+ Update(colToUpdate).
+ Where("name = ? OR age >= ?", "freedb", 10).
+ Exec(context.Background())
+```
+
+### Deleting Rows
+
+```go
+// Delete all rows
+err := store.
+ Delete().
+ Exec(context.Background())
+
+// Delete rows with conditions
+err := store.
+ Delete().
+ Where("name = ? OR age >= ?", "freedb", 10).
+ Exec(context.Background())
+```
+
+### Struct Field to Column Mapping
+
+The struct field tag `db` can be used for defining the mapping between the struct field and the column name.
+This works just like the `json` tag from [`encoding/json`](https://pkg.go.dev/encoding/json).
+
+Without `db` tag, the library will use the field name directly (case-sensitive).
+
+```go
+// This will map to the exact column name of "Name" and "Age".
+type NoTagPerson struct {
+ Name string
+ Age int
+}
+
+// This will map to the exact column name of "name" and "age"
+type WithTagPerson struct {
+ Name string `db:"name"`
+ Age int `db:"age"`
+}
+```
+
+## KV Store
+
+> Please use `KV Store V2` as much as possible, especially if you are creating a new storage.
+
+```go
+import (
+ "github.com/FreeLeh/GoFreeDB"
+ "github.com/FreeLeh/GoFreeDB/google/auth"
+)
+
+// If using Google Service Account.
+auth, err := auth.NewServiceFromFile(
+ "",
+ freedb.FreeDBGoogleAuthScopes,
+ auth.ServiceConfig{},
+)
+
+// If using Google OAuth2 Flow.
+auth, err := auth.NewOAuth2FromFile(
+ "",
+ "",
+ freedb.FreeDBGoogleAuthScopes,
+ auth.OAuth2Config{},
+)
+
+kv := freedb.NewGoogleSheetKVStore(
+ auth,
+ "",
+ "",
+ freedb.GoogleSheetKVStoreConfig{Mode: freedb.KVSetModeAppendOnly},
+)
+defer kv.Close(context.Background())
+```
+
+### Get Value
+
+If the key is not found, `freedb.ErrKeyNotFound` will be returned.
+
+```go
+value, err := kv.Get(context.Background(), "k1")
+```
+
+### Set Key
+
+```go
+err := kv.Set(context.Background(), "k1", []byte("some_value"))
+```
+
+### Delete Key
+
+```go
+err := kv.Delete(context.Background(), "k1")
+```
+
+### Supported Modes
+
+> For more details on how the two modes are different, please read the [protocol document](https://github.com/FreeLeh/docs/blob/main/freedb/protocols.md).
+
+There are 2 different modes supported:
+
+1. Default mode.
+2. Append only mode.
+
+```go
+// Default mode
+kv := freedb.NewGoogleSheetKVStore(
+ auth,
+ "",
+ "",
+ freedb.GoogleSheetKVStoreConfig{Mode: freedb.KVModeDefault},
+)
+
+// Append only mode
+kv := freedb.NewGoogleSheetKVStore(
+ auth,
+ "",
+ "",
+ freedb.GoogleSheetKVStoreConfig{Mode: freedb.KVModeAppendOnly},
+)
+```
+
+## KV Store V2
+
+The KV Store V2 is implemented internally using the row store.
+
+> The original `KV Store` was created using more complicated formulas, making it less maintainable.
+> You can still use the original `KV Store` implementation, but we strongly suggest using this new `KV Store V2`.
+
+You cannot use an existing sheet based on `KV Store` with `KV Store V2` as the sheet structure is different.
+- If you want to convert an existing sheet, just add an `_rid` column and insert the first key-value row with `1`
+ and increase it by 1 until the last row.
+- Remove the timestamp column as `KV Store V2` does not depend on it anymore.
+
+```go
+import (
+ "github.com/FreeLeh/GoFreeDB"
+ "github.com/FreeLeh/GoFreeDB/google/auth"
+)
+
+// If using Google Service Account.
+auth, err := auth.NewServiceFromFile(
+ "",
+ freedb.FreeDBGoogleAuthScopes,
+ auth.ServiceConfig{},
+)
+
+// If using Google OAuth2 Flow.
+auth, err := auth.NewOAuth2FromFile(
+ "",
+ "",
+ freedb.FreeDBGoogleAuthScopes,
+ auth.OAuth2Config{},
+)
+
+kv := freedb.NewGoogleSheetKVStoreV2(
+ auth,
+ "",
+ "",
+ freedb.GoogleSheetKVStoreV2Config{Mode: freedb.KVSetModeAppendOnly},
+)
+defer kv.Close(context.Background())
+```
+
+### Get Value V2
+
+If the key is not found, `freedb.ErrKeyNotFound` will be returned.
+
+```go
+value, err := kv.Get(context.Background(), "k1")
+```
+
+### Set Key V2
+
+```go
+err := kv.Set(context.Background(), "k1", []byte("some_value"))
+```
+
+### Delete Key V2
+
+```go
+err := kv.Delete(context.Background(), "k1")
+```
+
+### Supported Modes V2
+
+> For more details on how the two modes are different, please read the [protocol document](https://github.com/FreeLeh/docs/blob/main/freedb/protocols.md).
+
+There are 2 different modes supported:
+
+1. Default mode.
+2. Append only mode.
+
+```go
+// Default mode
+kv := freedb.NewGoogleSheetKVStoreV2(
+ auth,
+ "",
+ "",
+ freedb.GoogleSheetKVStoreV2Config{Mode: freedb.KVModeDefault},
+)
+
+// Append only mode
+kv := freedb.NewGoogleSheetKVStoreV2(
+ auth,
+ "",
+ "",
+ freedb.GoogleSheetKVStoreV2Config{Mode: freedb.KVModeAppendOnly},
+)
+```
+
+## License
+
+This project is [MIT licensed](https://github.com/FreeLeh/GoFreeDB/blob/main/LICENSE).
diff --git a/jest.config.js b/jest.config.js
index 2b0a1d3..1f02ea3 100644
--- a/jest.config.js
+++ b/jest.config.js
@@ -6,5 +6,8 @@ module.exports = {
}
},
testEnvironment: 'node',
- testMatch: ['**/tests/**/*.[jt]s?(x)', '**/?(*.)+(spec|test).[tj]s?(x)'],
+ testMatch: ['**/tests/**/*.(spec|test).[jt]s?(x)', '**/?(*.)+(spec|test).[tj]s?(x)'],
+ collectCoverage: true,
+ coverageDirectory: 'coverage',
+ coverageReporters: ['text', 'lcov', 'json-summary'],
};
\ No newline at end of file
diff --git a/package.json b/package.json
index b0d9bfe..eaba04b 100644
--- a/package.json
+++ b/package.json
@@ -4,7 +4,8 @@
"description": "JSFreeDB is a JavaScript library that provides common and simple database abstractions on top of Google Sheets.",
"main": "dist/index.js",
"scripts": {
- "test": "jest"
+ "test": "jest",
+ "integration-test": "npm run test -- --testPathPattern=integration"
},
"repository": {
"type": "git",
@@ -27,4 +28,4 @@
"axios": "^1.8.1",
"googleapis": "^105.0.0"
}
-}
+}
\ No newline at end of file
diff --git a/src/google/auth/models.ts b/src/google/auth/models.ts
new file mode 100644
index 0000000..e48c159
--- /dev/null
+++ b/src/google/auth/models.ts
@@ -0,0 +1,3 @@
+export const GOOGLE_SHEETS_READ_ONLY: string[] = ['https://www.googleapis.com/auth/spreadsheets.readonly']
+export const GOOGLE_SHEETS_WRITE_ONLY: string[] = ['https://www.googleapis.com/auth/spreadsheets']
+export const GOOGLE_SHEETS_READ_WRITE: string[] = ['https://www.googleapis.com/auth/spreadsheets']
diff --git a/src/google/auth/service_account.ts b/src/google/auth/service_account.ts
index d543533..cac84b8 100644
--- a/src/google/auth/service_account.ts
+++ b/src/google/auth/service_account.ts
@@ -2,7 +2,7 @@ import * as google from 'googleapis';
import { AuthClient } from './base';
-export default class ServiceAccountGoogleAuthClient implements AuthClient {
+export class ServiceAccountGoogleAuthClient implements AuthClient {
private auth!: google.Auth.GoogleAuth;
private constructor(auth: google.Auth.GoogleAuth) {
@@ -10,9 +10,8 @@ export default class ServiceAccountGoogleAuthClient implements AuthClient {
}
public static fromServiceAccountInfo(serviceAccountInfo: google.Auth.JWTInput, scopes: string[]): ServiceAccountGoogleAuthClient {
- const jsonAuthClient = new google.Auth.GoogleAuth().fromJSON(serviceAccountInfo);
const authClient = new google.Auth.GoogleAuth({
- authClient: jsonAuthClient,
+ credentials: serviceAccountInfo,
scopes: scopes,
});
return new ServiceAccountGoogleAuthClient(authClient);
diff --git a/src/google/sheets/models.ts b/src/google/sheets/models.ts
index 9b95771..c4c662b 100644
--- a/src/google/sheets/models.ts
+++ b/src/google/sheets/models.ts
@@ -1,5 +1,3 @@
-import { OAuth2Client } from 'google-auth-library';
-
export const MAJOR_DIMENSION_ROWS = "ROWS";
export const VALUE_INPUT_USER_ENTERED = "USER_ENTERED";
export const RESPONSE_VALUE_RENDER_FORMATTED = "FORMATTED_VALUE";
diff --git a/src/google/sheets/wrapper.ts b/src/google/sheets/wrapper.ts
index 1a4bf48..60cffa0 100644
--- a/src/google/sheets/wrapper.ts
+++ b/src/google/sheets/wrapper.ts
@@ -2,6 +2,7 @@ import { google, sheets_v4 } from 'googleapis';
import axios, { AxiosInstance } from 'axios';
import { GoogleAuth } from 'google-auth-library';
+import { AuthClient } from '../auth/base';
import {
AppendMode,
A1Range,
@@ -19,13 +20,13 @@ import {
} from './models';
export class Wrapper {
- private auth: GoogleAuth;
+ private googleAuth: GoogleAuth;
private service: sheets_v4.Sheets;
private rawClient: AxiosInstance;
- constructor(auth: GoogleAuth) {
- this.auth = auth;
- this.service = google.sheets({ version: 'v4', auth });
+ constructor(auth: AuthClient) {
+ this.googleAuth = auth.getAuth();
+ this.service = google.sheets({ version: 'v4', auth: this.googleAuth });
this.rawClient = axios.create({ validateStatus: () => true });
}
@@ -67,7 +68,7 @@ export class Wrapper {
/**
* Gets a mapping of sheet names to sheet IDs
*/
- async getSheetNameToID(spreadsheetId: string): Promise