Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
node_modules
build
Code.js
Code.js
tsconfig.json
backup/
197 changes: 197 additions & 0 deletions EXTERNAL_SPREADSHEET_GUIDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
# External Spreadsheet Support

This document describes how to use external spreadsheet support in the GASS framework.

## Overview

The GASS framework now supports read-only access to external Google Spreadsheets. This allows you to create Entry classes that read data from spreadsheets outside of your current active spreadsheet, using the Google Sheets API.

## Key Features

- **Read-only access**: External entries are automatically protected from save/update/delete operations
- **Filtering support**: Use the same filtering syntax as internal entries
- **A1 notation**: Automatically generates proper A1 notation for external sheet ranges
- **Batch optimization**: Uses `batchGet` for efficient filtering operations
- **Backward compatibility**: Works alongside existing internal entry types

## Basic Usage

### 1. Define an External Entry

```typescript
import { Entry, IEntryMeta } from "gass";

export class Affiliate extends Entry {
static override _meta: IEntryMeta = {
// Required: External spreadsheet ID
spreadsheetId: "1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms",
// Required: Sheet name (string, not numeric ID for external sheets)
sheetId: "Affiliates",
headerRow: 1,
dataStartColumn: 1,
dataEndColumn: 5,
columns: ["name", "representative", "address", "website", "notes"],
defaultSort: [{ column: "name", ascending: true }],
};

// Define your properties
name: string = "";
representative: string = "";
address: string = "";
website: string = "";
notes: string = "";

getCacheKey(): string {
return `affiliate_${this.name}`;
}

validate() {
const errors: string[] = [];
if (!this.name) errors.push("Name is required");
if (!this.representative) errors.push("Representative is required");

return {
isValid: errors.length === 0,
errors
};
}
}
```

### 2. Query External Data

```typescript
// Get filtered results (as described in the issue)
const affiliates = await Affiliate.get({ representative: "Kevin Shenk" });

// Get all entries
const allAffiliates = await Affiliate.getAll();

// Get a specific value
const website = await Affiliate.getValue({ name: "Acme Corp" }, "website");
```

## Configuration

### IEntryMeta for External Sheets

When `spreadsheetId` is present in the metadata, the entry is treated as external:

```typescript
interface IEntryMeta {
sheetId: number | string; // string for external sheets (sheet name)
spreadsheetId?: string; // when present, indicates external sheet
columns: string[];
headerRow: number;
dataStartColumn: number;
dataEndColumn: number;
// ... other properties
}
```

### Key Differences from Internal Entries

| Property | Internal Entries | External Entries |
|----------|------------------|------------------|
| `sheetId` | `number` (sheet ID) | `string` (sheet name) |
| `spreadsheetId` | `undefined` | `string` (spreadsheet ID) |
| Write operations | ✅ Supported | ❌ Read-only |
| Filters/sorting | ✅ Supported | ❌ Not supported |
| Caching | ✅ Full caching | ⚠️ Limited caching |

## Behind the Scenes

### A1 Notation Generation

The framework automatically generates proper A1 notation for external sheet operations:

```typescript
// For filtering by "representative" column (column B in this example)
// Generates: "Affiliates!B2:B"

// For getting complete rows
// Generates: "Affiliates!A2:E" (based on dataStartColumn and dataEndColumn)
```

### Batch Optimization

When filtering external entries, the system:

1. Uses `Sheets.Spreadsheets.Values.batchGet` to fetch only the filter columns
2. Applies filter logic to find matching row numbers
3. Uses `Sheets.Spreadsheets.Values.get` to fetch complete data for matching rows

This approach minimizes API calls and data transfer.

## Error Handling

### Read-only Protection

Attempting to save external entries will throw an error:

```typescript
const affiliate = affiliates[0];
affiliate.notes = "Updated notes";
affiliate.markDirty();

try {
await affiliate.save();
} catch (error) {
// Error: "Cannot save external entries - they are read-only"
}
```

### Unsupported Operations

Filter and sort operations are not supported on external entries:

```typescript
// These will throw errors:
Affiliate.applyFilter(criteria, column); // Error
Affiliate.sort(sortOrders); // Error
Affiliate.clearFilters(); // Error
```

## Registry Support

External entries can be registered alongside internal entries:

```typescript
import { EntryRegistry } from "gass";

// Register both internal and external entry types
EntryRegistry.init([
InternalEntry, // Traditional internal sheet entry
Affiliate, // External sheet entry
// ... more entries
]);
```

## Permissions

Before using external spreadsheet support, ensure you have:

1. **Spreadsheet access**: Read permission to the external spreadsheet
2. **Sheets API enabled**: The Google Sheets API must be enabled for your project
3. **Proper authentication**: The script must run with appropriate permissions

## Limitations

- **Read-only**: External entries cannot be modified, saved, or deleted
- **No triggers**: External sheets don't support onChange triggers or action hooks
- **No filters**: Built-in filter operations are not supported
- **Performance**: External operations are slower than internal sheet operations due to API calls

## Best Practices

1. **Cache results**: Store frequently accessed external data in internal sheets when possible
2. **Minimize calls**: Use specific filters to reduce the amount of data fetched
3. **Error handling**: Always wrap external operations in try-catch blocks
4. **Permissions**: Verify external spreadsheet access before deployment

## Example Use Cases

- **Reference data**: Product catalogs, price lists, or configuration data stored in shared spreadsheets
- **Reporting**: Aggregating data from multiple department spreadsheets
- **Master data**: Accessing centralized customer, vendor, or employee data
- **Cross-team collaboration**: Reading data maintained by other teams
144 changes: 144 additions & 0 deletions IMPLEMENTATION_SUMMARY.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
# External Spreadsheet Support - Implementation Summary

## ✅ Successfully Implemented

This implementation adds comprehensive external spreadsheet support to the GASS framework, exactly as specified in the GitHub issue.

### Core Features

1. **External Entry Detection**
- Added `spreadsheetId?: string` to `IEntryMeta` interface
- Modified `sheetId` to accept `number | string` (string for external sheet names)
- Automatic detection of external vs internal entries

2. **Read-only Access**
- External entries automatically prevented from save/update/delete operations
- Throws meaningful error messages when attempting write operations
- Maintains data integrity by preventing accidental modifications

3. **Optimized API Usage**
- Uses `Sheets.Spreadsheets.Values.batchGet` for filtering operations
- Minimizes API calls by fetching only required columns for filtering
- Uses `Sheets.Spreadsheets.Values.get` for complete row retrieval

4. **A1 Notation Generation**
- Automatic conversion from column numbers to A1 notation (1→A, 27→AA, etc.)
- Dynamic range generation based on sheet metadata
- Proper handling of sheet names with spaces/special characters

### Exact Issue Implementation

The implementation provides the exact functionality described in the issue:

```typescript
// Exact example from the issue
export class Affiliate extends Entry {
static override _meta: IEntryMeta = {
spreadsheetId: SPREADSHEET_ID,
sheetId: SHEET_ID, // Sheet name for external sheets
headerRow: 1,
dataStartColumn: 1,
dataEndColumn: 5,
columns: ["name", "representative", "address", "website", "notes"],
defaultSort: [{ column: "name", ascending: true }],
};
}

// The exact filtering scenario from the issue
Affiliate.get({ representative: "Kevin Shenk" })
```

This generates the optimal API calls as specified:
1. `Sheets.Spreadsheets.Values.batchGet(SPREADSHEET_ID, { ranges: ["SheetName!B2:B"] })`
2. Apply filter logic to find matching rows
3. `Sheets.Spreadsheets.Values.get(spreadsheetId, range)` for complete row data

### API Coverage

✅ **Entry.get(filters)** - Filtered retrieval with external optimization
✅ **Entry.getAll()** - Bulk retrieval from external sheets
✅ **Entry.getValue(filters, column)** - Single value extraction
✅ **Entry.save()** - Properly blocked for external entries
✅ **Entry.delete()** - Properly blocked for external entries
✅ **Entry.batchSave()** - Properly blocked for external entries

### Registry Integration

✅ **EntryRegistry.init()** - Supports mixed internal/external entries
✅ **EntryRegistry.getEntryTypeBySheetId()** - Internal entry lookup
✅ **EntryRegistry.getEntryTypeByExternalId()** - External entry lookup

### Error Handling

✅ **Read-only protection** - Clear error messages for write attempts
✅ **Invalid operations** - Proper blocking of filter/sort operations on external entries
✅ **Missing permissions** - Graceful handling of access errors
✅ **Column validation** - Verification of column names in filters

### Documentation & Examples

✅ **Comprehensive guide** - `EXTERNAL_SPREADSHEET_GUIDE.md`
✅ **Working examples** - `example-external-usage.ts`
✅ **Unit tests** - `tests.ts` with core functionality validation
✅ **API explanations** - Behind-the-scenes documentation

### Backward Compatibility

✅ **Existing code unchanged** - All current internal entries continue to work
✅ **Same API surface** - External entries use identical method signatures
✅ **Registry compatibility** - Mixed registration of internal and external entries
✅ **Type safety** - Full TypeScript support with proper type constraints

## Testing

The implementation includes comprehensive testing:

- **A1 notation generation**: ✅ Verified
- **Column number conversion**: ✅ Verified
- **External entry detection**: ✅ Verified
- **Filter evaluation**: ✅ Verified
- **Read-only enforcement**: ✅ Verified

## Usage Example

```typescript
import { Entry, IEntryMeta, EntryRegistry } from "gass";

// Define external entry
class ExternalData extends Entry {
static override _meta: IEntryMeta = {
spreadsheetId: "1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms",
sheetId: "DataSheet",
headerRow: 1,
dataStartColumn: 1,
dataEndColumn: 3,
columns: ["id", "name", "status"]
};

id: string = "";
name: string = "";
status: string = "";

getCacheKey() { return `external_${this.id}`; }
validate() { return { isValid: true, errors: [] }; }
}

// Register alongside internal entries
EntryRegistry.init([InternalEntry, ExternalData]);

// Use exactly like internal entries
const results = await ExternalData.get({ status: "active" });
const allData = await ExternalData.getAll();
const specificValue = await ExternalData.getValue({ id: "123" }, "name");
```

## Benefits

1. **Minimal API calls** - Batch operations reduce quota usage
2. **Type safety** - Full TypeScript support
3. **Easy migration** - Simple addition of `spreadsheetId` to existing patterns
4. **Data integrity** - Read-only protection prevents accidental modifications
5. **Performance** - Optimized filtering reduces data transfer
6. **Flexibility** - Mix internal and external entries in same application

The implementation fully satisfies the requirements from the GitHub issue while maintaining the framework's design principles and providing a seamless developer experience.
Loading