tantan-typeorm-gs 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +31 -0
  3. package/bun.lock +300 -0
  4. package/docs/authentication.md +118 -0
  5. package/docs/configuration.md +680 -0
  6. package/docs/contributing.md +177 -0
  7. package/docs/crud.md +351 -0
  8. package/docs/custom-client.md +165 -0
  9. package/docs/development.md +243 -0
  10. package/docs/entities.md +502 -0
  11. package/docs/find-options.md +296 -0
  12. package/docs/google-cloud.md +101 -0
  13. package/docs/installation.md +51 -0
  14. package/docs/integration-testing.md +178 -0
  15. package/docs/limitations.md +223 -0
  16. package/docs/pagination.md +384 -0
  17. package/docs/performance.md +171 -0
  18. package/docs/security.md +153 -0
  19. package/docs/sorting.md +0 -0
  20. package/docs/supported-features.md +310 -0
  21. package/docs/synchronization.md +203 -0
  22. package/package.json +43 -0
  23. package/src/client/index.ts +589 -0
  24. package/src/core/data-source.ts +47 -0
  25. package/src/core/driver.ts +290 -0
  26. package/src/core/error.ts +144 -0
  27. package/src/core/memory.ts +152 -0
  28. package/src/core/query/interpreter.ts +949 -0
  29. package/src/core/query/runner.ts +1297 -0
  30. package/src/core/query/types.ts +155 -0
  31. package/src/core/schema-builder.ts +64 -0
  32. package/src/core/types.ts +113 -0
  33. package/src/core/utils.ts +13 -0
  34. package/src/index.ts +3 -0
  35. package/tantan-typeorm-gs.code-workspace +8 -0
  36. package/test/base/0001-data-source.test.ts +252 -0
  37. package/test/base/0002-operator.test.ts +616 -0
  38. package/test/base/0003-select.test.ts +281 -0
  39. package/test/base/0004-aggregate.test.ts +213 -0
  40. package/test/base/0005-transcation.test.ts +1566 -0
  41. package/test/base/0006-relation.test.ts +1611 -0
  42. package/test/base/0007-logging.test.ts +182 -0
  43. package/test/base/0008-soft-delete.test.ts +649 -0
  44. package/test/google-sheets/0001-client.test.ts +1705 -0
  45. package/test/google-sheets/0002-worksheet-management.test.ts +408 -0
  46. package/test/google-sheets/0003-data-source.test.ts +1935 -0
  47. package/test/google-sheets/0004-transactions.test.ts +952 -0
  48. package/test/google-sheets/0005-operator.test.ts +1124 -0
  49. package/test/google-sheets/0006-object-criteria.test.ts +283 -0
  50. package/test/google-sheets/0007-performance.test.ts +532 -0
  51. package/test/public-api.test.ts +34 -0
  52. package/tsconfig.build.json +21 -0
  53. package/tsconfig.json +34 -0
@@ -0,0 +1,589 @@
1
+ import { google, type sheets_v4 } from "googleapis";
2
+ import
3
+ {
4
+ DEFAULT_MAX_RETRIES,
5
+ type GoogleSheetsClient,
6
+ type GoogleSheetsConsumeOptions,
7
+ type GoogleSheetsRow,
8
+ type GoogleSheetsSheetMetadata
9
+ } from "../core/types.js";
10
+ import
11
+ {
12
+ GoogleSheetsAuthenticationError,
13
+ GoogleSheetsGenaralError,
14
+ GoogleSheetsInvalidRangeError,
15
+ GoogleSheetsSpreadsheetNotFoundError,
16
+ GoogleSheetsWorksheetNotFoundError
17
+ } from "../core/error.js";
18
+ import { buildWorksheetRange } from "../core/utils.js";
19
+
20
+ export class GoogleSheetsConsume implements GoogleSheetsClient
21
+ {
22
+ private readonly spreadsheetId: string;
23
+ private readonly clientEmail: string;
24
+ private readonly privateKey: string;
25
+ private sheets?: sheets_v4.Sheets;
26
+ private readonly metadataCache = new Map<string, GoogleSheetsSheetMetadata | null>();
27
+ private readonly headersCache = new Map<string, string[]>();
28
+
29
+ constructor(
30
+ options: GoogleSheetsConsumeOptions,
31
+ )
32
+ {
33
+ this.spreadsheetId = options.spreadsheetId;
34
+ this.clientEmail = options.credentials.clientEmail;
35
+ this.privateKey = options.credentials.privateKey;
36
+ }
37
+
38
+ async connect(): Promise<void>
39
+ {
40
+ const auth = new google.auth.GoogleAuth({
41
+ credentials: {
42
+ client_email: this.clientEmail,
43
+ private_key: this.privateKey,
44
+ },
45
+
46
+ scopes: ['https://www.googleapis.com/auth/spreadsheets'],
47
+ });
48
+
49
+ this.sheets = google.sheets({ version: 'v4', auth });
50
+ }
51
+
52
+ async disconnect(): Promise<void>
53
+ {
54
+ this.sheets = undefined;
55
+ }
56
+
57
+ async hasSheet(sheetName: string): Promise<boolean>
58
+ {
59
+ if (!this.sheets)
60
+ {
61
+ throw new GoogleSheetsGenaralError('Google Sheets client is not connected.');
62
+ }
63
+
64
+ const response = await this.sheets.spreadsheets.get({
65
+ spreadsheetId: this.spreadsheetId,
66
+ fields: 'sheets.properties.title',
67
+ });
68
+
69
+ return (response.data.sheets?.some((sheet) => sheet.properties?.title === sheetName) ?? false);
70
+ }
71
+
72
+ async createSheet(sheetName: string): Promise<void>
73
+ {
74
+ if (!this.sheets)
75
+ {
76
+ throw new GoogleSheetsGenaralError('Google Sheets client is not connected.');
77
+ }
78
+
79
+ await this.sheets.spreadsheets.batchUpdate({
80
+ spreadsheetId: this.spreadsheetId,
81
+ requestBody: {
82
+ requests: [
83
+ { addSheet: { properties: { title: sheetName } } },
84
+ ],
85
+ },
86
+ });
87
+
88
+ this.metadataCache.delete(sheetName);
89
+ this.headersCache.delete(sheetName);
90
+ }
91
+
92
+ async getSheetMetadata(sheetName: string): Promise<GoogleSheetsSheetMetadata | null>
93
+ {
94
+ if (!this.sheets)
95
+ {
96
+ throw new GoogleSheetsGenaralError('GoogleSheetsApiClient is not connected.');
97
+ }
98
+
99
+ if (this.metadataCache.has(sheetName)) return this.metadataCache.get(sheetName) ?? null;
100
+
101
+ const response = await this.sheets.spreadsheets.get({
102
+ spreadsheetId: this.spreadsheetId,
103
+ fields: 'sheets.properties',
104
+ });
105
+
106
+ const sheet = response.data.sheets?.find((item) => item.properties?.title === sheetName);
107
+
108
+ if (!sheet?.properties?.sheetId || !sheet.properties.title)
109
+ {
110
+ this.metadataCache.set(sheetName, null);
111
+
112
+ return null;
113
+ }
114
+
115
+ const metadata: GoogleSheetsSheetMetadata = {
116
+ sheetId: sheet.properties.sheetId,
117
+ title: sheet.properties.title,
118
+ };
119
+
120
+ this.metadataCache.set(sheetName, metadata);
121
+
122
+ return metadata;
123
+ }
124
+
125
+ async getHeaders(sheetName: string): Promise<string[]>
126
+ {
127
+ if (!this.sheets)
128
+ {
129
+ throw new GoogleSheetsGenaralError('GoogleSheetsApiClient is not connected.');
130
+ }
131
+
132
+ const cachedHeaders = this.headersCache.get(sheetName);
133
+
134
+ if (cachedHeaders) return [...cachedHeaders];
135
+
136
+ const response =
137
+ await this.sheets.spreadsheets.values.get({
138
+ spreadsheetId: this.spreadsheetId,
139
+ range: buildWorksheetRange(sheetName, '1:1'),
140
+ });
141
+
142
+ const values = response.data.values?.[0] ?? [];
143
+
144
+ const headers = values.map((value) => String(value));
145
+
146
+ this.headersCache.set(sheetName, [...headers]);
147
+
148
+ return headers;
149
+ }
150
+
151
+ async insertHeaders(sheetName: string, headers: string[]): Promise<void>
152
+ {
153
+ if (!this.sheets)
154
+ {
155
+ throw new GoogleSheetsGenaralError('GoogleSheetsApiClient is not connected.');
156
+ }
157
+
158
+ if (headers.length === 0) return;
159
+
160
+ await this.sheets.spreadsheets.values.update({
161
+ spreadsheetId: this.spreadsheetId,
162
+ range: buildWorksheetRange(sheetName, 'A1'),
163
+ valueInputOption: 'RAW',
164
+ requestBody: {
165
+ values: [headers],
166
+ },
167
+ });
168
+
169
+ this.headersCache.delete(sheetName);
170
+ }
171
+
172
+ async renameSheet(oldSheetName: string, newSheetName: string): Promise<void>
173
+ {
174
+ if (!this.sheets)
175
+ {
176
+ throw new GoogleSheetsGenaralError('GoogleSheetsApiClient is not connected.');
177
+ }
178
+
179
+ const response = await this.sheets.spreadsheets.get({
180
+ spreadsheetId: this.spreadsheetId,
181
+ fields: 'sheets.properties',
182
+ });
183
+
184
+ const sheet = response.data.sheets?.find((item) => item.properties?.title === oldSheetName);
185
+
186
+ if (!sheet?.properties?.sheetId) return;
187
+
188
+ await this.sheets.spreadsheets.batchUpdate({
189
+ spreadsheetId: this.spreadsheetId,
190
+ requestBody: {
191
+ requests: [
192
+ {
193
+ updateSheetProperties: {
194
+ properties: {
195
+ sheetId: sheet.properties.sheetId,
196
+ title: newSheetName,
197
+ },
198
+ fields: 'title',
199
+ },
200
+ },
201
+ ],
202
+ },
203
+ });
204
+
205
+ const metadata = this.metadataCache.get(oldSheetName);
206
+
207
+ this.metadataCache.delete(oldSheetName);
208
+ this.headersCache.delete(oldSheetName);
209
+
210
+ if (metadata !== undefined)
211
+ {
212
+ this.metadataCache.set(newSheetName, metadata === null ? null : { ...metadata, title: newSheetName });
213
+ }
214
+ }
215
+
216
+ async deleteSheet(sheetName: string): Promise<void>
217
+ {
218
+ if (!this.sheets)
219
+ {
220
+ throw new GoogleSheetsGenaralError('GoogleSheetsApiClient is not connected.');
221
+ }
222
+
223
+ const response = await this.sheets.spreadsheets.get({
224
+ spreadsheetId: this.spreadsheetId,
225
+ fields: 'sheets.properties',
226
+ });
227
+
228
+ const sheet = response.data.sheets?.find((item) => item.properties?.title === sheetName);
229
+
230
+ if (!sheet?.properties?.sheetId) return;
231
+
232
+ await this.sheets.spreadsheets.batchUpdate({
233
+ spreadsheetId: this.spreadsheetId,
234
+ requestBody: {
235
+ requests: [
236
+ { deleteSheet: { sheetId: sheet.properties.sheetId } },
237
+ ],
238
+ },
239
+ });
240
+
241
+ this.metadataCache.delete(sheetName);
242
+ this.headersCache.delete(sheetName);
243
+ }
244
+
245
+ async getRows(sheetName: string): Promise<GoogleSheetsRow[]>
246
+ {
247
+ if (!this.sheets)
248
+ {
249
+ throw new GoogleSheetsGenaralError('GoogleSheetsApiClient is not connected.');
250
+ }
251
+
252
+ const response =
253
+ await this.withRetry(
254
+ () => this.sheets!.spreadsheets.values.get({
255
+ spreadsheetId: this.spreadsheetId,
256
+ range: sheetName,
257
+ }),
258
+ );
259
+
260
+ const values = response.data.values ?? [];
261
+
262
+ if (values.length === 0) return [];
263
+
264
+ const headerRow = values[0];
265
+
266
+ if (!headerRow) return [];
267
+
268
+ const headers = headerRow.map((header) => String(header));
269
+
270
+ return values.slice(1).map((values) =>
271
+ {
272
+ const row: GoogleSheetsRow = {};
273
+
274
+ for (let index = 0; index < headers.length; index++)
275
+ {
276
+ const header = headers[index];
277
+
278
+ if (header === undefined) continue;
279
+
280
+ row[header] = values[index];
281
+ }
282
+
283
+ return row;
284
+ });
285
+ }
286
+
287
+ async insertRows(sheetName: string, rows: GoogleSheetsRow[]): Promise<void>
288
+ {
289
+ if (!this.sheets)
290
+ {
291
+ throw new GoogleSheetsGenaralError('GoogleSheetsApiClient is not connected.');
292
+ }
293
+
294
+ if (rows.length === 0) return;
295
+
296
+ const headers = await this.getHeaders(sheetName);
297
+ const values = rows.map((row) => headers.map((header) => row[header] ?? ''));
298
+
299
+ await this.sheets.spreadsheets.values.append({
300
+ spreadsheetId: this.spreadsheetId,
301
+ range: sheetName,
302
+ valueInputOption: 'RAW',
303
+ insertDataOption: 'INSERT_ROWS',
304
+ requestBody: {
305
+ values,
306
+ },
307
+ });
308
+ }
309
+
310
+ async updateRows(
311
+ sheetName: string,
312
+ predicate: (
313
+ row: GoogleSheetsRow,
314
+ ) => boolean,
315
+ update: Partial<GoogleSheetsRow>,
316
+ ): Promise<number>
317
+ {
318
+ if (!this.sheets)
319
+ {
320
+ throw new GoogleSheetsGenaralError('GoogleSheetsApiClient is not connected.');
321
+ }
322
+
323
+ const response = await this.sheets.spreadsheets.values.get({
324
+ spreadsheetId: this.spreadsheetId,
325
+ range: sheetName,
326
+ });
327
+
328
+ const values = response.data.values ?? [];
329
+
330
+ if (values.length <= 1) return 0;
331
+
332
+ const headerRow = values[0];
333
+
334
+ if (!headerRow) return 0;
335
+
336
+ const headers = headerRow.map((header) => String(header));
337
+ const matchingRows: { rowNumber: number; values: unknown[]; }[] = [];
338
+
339
+ for (let index = 1; index < values.length; index++)
340
+ {
341
+ const valuesRow = values[index];
342
+
343
+ if (!valuesRow) continue;
344
+
345
+ const row: GoogleSheetsRow = {};
346
+
347
+ for (let columnIndex = 0; columnIndex < headers.length; columnIndex++)
348
+ {
349
+ const header = headers[columnIndex];
350
+
351
+ if (header === undefined) continue;
352
+
353
+ row[header] = valuesRow[columnIndex];
354
+ }
355
+
356
+ if (!predicate(row)) continue;
357
+
358
+ const updatedRow = { ...row, ...update };
359
+
360
+ matchingRows.push({
361
+ rowNumber: index + 1,
362
+ values: headers.map((header) => updatedRow[header] ?? '')
363
+ });
364
+
365
+ }
366
+
367
+
368
+ if (matchingRows.length === 0) return 0;
369
+
370
+ let groupStart = matchingRows[0]!;
371
+ let groupValues = [groupStart.values];
372
+
373
+ for (let index = 1; index < matchingRows.length; index++)
374
+ {
375
+ const current = matchingRows[index]!;
376
+ const previous = matchingRows[index - 1]!;
377
+
378
+ if (current.rowNumber === previous.rowNumber + 1)
379
+ {
380
+ groupValues.push(current.values);
381
+
382
+ continue;
383
+ }
384
+
385
+ await this.sheets.spreadsheets.values.update({
386
+ spreadsheetId: this.spreadsheetId,
387
+ range: buildWorksheetRange(sheetName, `A${groupStart.rowNumber}`),
388
+ valueInputOption: 'RAW',
389
+ requestBody: {
390
+ values: groupValues,
391
+ },
392
+ });
393
+
394
+ groupStart = current;
395
+ groupValues = [current.values];
396
+ }
397
+
398
+ await this.sheets.spreadsheets.values.update({
399
+ spreadsheetId: this.spreadsheetId,
400
+ range: buildWorksheetRange(sheetName, `A${groupStart.rowNumber}`),
401
+ valueInputOption: 'RAW',
402
+ requestBody: {
403
+ values: groupValues,
404
+ },
405
+ });
406
+
407
+ return matchingRows.length;
408
+ }
409
+
410
+ async deleteRows(
411
+ sheetName: string,
412
+ predicate: (
413
+ row: GoogleSheetsRow,
414
+ ) => boolean,
415
+ ): Promise<number>
416
+ {
417
+ if (!this.sheets)
418
+ {
419
+ throw new GoogleSheetsGenaralError('GoogleSheetsApiClient is not connected.');
420
+ }
421
+
422
+ const response = await this.sheets.spreadsheets.values.get({
423
+ spreadsheetId: this.spreadsheetId,
424
+ range: sheetName,
425
+ });
426
+
427
+ const values = response.data.values ?? [];
428
+
429
+ if (values.length <= 1) return 0;
430
+
431
+ const headerRow = values[0];
432
+
433
+ if (!headerRow) return 0;
434
+
435
+ const headers = headerRow.map((header) => String(header));
436
+ const rowNumbers: number[] = [];
437
+
438
+ for (let index = 1; index < values.length; index++)
439
+ {
440
+ const valuesRow = values[index];
441
+
442
+ if (!valuesRow) continue;
443
+
444
+ const row: GoogleSheetsRow = {};
445
+
446
+ for (let columnIndex = 0; columnIndex < headers.length; columnIndex++)
447
+ {
448
+ const header = headers[columnIndex];
449
+
450
+ if (header === undefined) continue;
451
+
452
+ row[header] = valuesRow[columnIndex];
453
+ }
454
+
455
+ if (predicate(row)) rowNumbers.push(index + 1);
456
+ }
457
+
458
+ if (rowNumbers.length === 0) return 0;
459
+
460
+ const spreadsheetResponse = await this.sheets.spreadsheets.get({
461
+ spreadsheetId: this.spreadsheetId,
462
+ fields: 'sheets.properties',
463
+ });
464
+
465
+ const sheet = spreadsheetResponse.data.sheets?.find((sheet) => sheet.properties?.title === sheetName);
466
+ const sheetId = sheet?.properties?.sheetId;
467
+
468
+ if (sheetId === undefined)
469
+ {
470
+ throw new GoogleSheetsWorksheetNotFoundError(`Worksheet "${sheetName}" not found.`);
471
+ }
472
+
473
+ await this.sheets.spreadsheets.batchUpdate({
474
+ spreadsheetId: this.spreadsheetId,
475
+ requestBody: {
476
+ requests: rowNumbers.sort((a, b) => b - a).map((rowNumber) => ({
477
+ deleteDimension: {
478
+ range: {
479
+ sheetId,
480
+ dimension: 'ROWS',
481
+ startIndex: rowNumber - 1,
482
+ endIndex: rowNumber,
483
+ },
484
+ },
485
+ }),
486
+ ),
487
+ },
488
+ });
489
+
490
+ return rowNumbers.length;
491
+ }
492
+
493
+ private async withRetry<T>(operation: () => Promise<T>): Promise<T>
494
+ {
495
+ let lastError: unknown;
496
+
497
+ for (let attempt = 0; attempt <= DEFAULT_MAX_RETRIES; attempt++)
498
+ {
499
+ try
500
+ {
501
+ return await operation();
502
+ }
503
+ catch (error)
504
+ {
505
+ lastError = error;
506
+
507
+ if (!this.isRetryableError(error) || attempt === DEFAULT_MAX_RETRIES)
508
+ {
509
+ if (this.isAuthenticationError(error))
510
+ {
511
+ throw new GoogleSheetsAuthenticationError('Google Sheets authentication failed.', { cause: error });
512
+ }
513
+
514
+ if (this.isSpreadsheetNotFoundError(error))
515
+ {
516
+ throw new GoogleSheetsSpreadsheetNotFoundError('Google Sheets spreadsheet not found.', { cause: error });
517
+ }
518
+
519
+ if (this.isInvalidRangeError(error))
520
+ {
521
+ throw new GoogleSheetsInvalidRangeError('Google Sheets range is invalid.', { cause: error });
522
+ }
523
+
524
+ throw error;
525
+ }
526
+
527
+ const retryAfter = this.getRetryAfter(error);
528
+ const delay = retryAfter ?? 100 * 2 ** attempt;
529
+
530
+ await new Promise<void>((resolve) => setTimeout(resolve, delay));
531
+ }
532
+ }
533
+
534
+ throw lastError;
535
+ }
536
+
537
+ private getRetryAfter(error: unknown): number | null
538
+ {
539
+ const response = (error as { response?: { headers?: Record<string, unknown> } }).response;
540
+ const value = response?.headers?.['retry-after'];
541
+
542
+ if (typeof value !== 'string' && typeof value !== 'number') return null;
543
+
544
+ const seconds = Number(value);
545
+
546
+ if (!Number.isFinite(seconds) || seconds < 0) return null;
547
+
548
+ return seconds * 1000;
549
+ }
550
+
551
+ private isRetryableError(error: unknown): boolean
552
+ {
553
+ if (typeof error !== 'object' || error === null) return false;
554
+
555
+ const status = 'response' in error && typeof error.response === 'object' && error.response !== null &&
556
+ 'status' in error.response
557
+ ? error.response.status
558
+ : undefined;
559
+
560
+ return (status === 429 || status === 500 || status === 502 || status === 503 || status === 504);
561
+ }
562
+
563
+ private isAuthenticationError(error: unknown): boolean
564
+ {
565
+ if (typeof error !== 'object' || error === null) return false;
566
+
567
+ const response = (error as { response?: { status?: unknown } }).response;
568
+
569
+ return response?.status === 401;
570
+ }
571
+
572
+ private isSpreadsheetNotFoundError(error: unknown): boolean
573
+ {
574
+ if (typeof error !== 'object' || error === null) return false;
575
+
576
+ const response = (error as { response?: { status?: unknown } }).response;
577
+
578
+ return response?.status === 404;
579
+ }
580
+
581
+ private isInvalidRangeError(error: unknown): boolean
582
+ {
583
+ if (typeof error !== 'object' || error === null) return false;
584
+
585
+ const response = (error as { response?: { status?: unknown } }).response;
586
+
587
+ return response?.status === 400;
588
+ }
589
+ }
@@ -0,0 +1,47 @@
1
+ import { DataSource, type DataSourceOptions } from "typeorm";
2
+ import { DriverFactory } from "typeorm/driver/DriverFactory.js";
3
+ import type { GoogleSheetsClient, GoogleSheetsDataSourceOptions } from "./types.js";
4
+ import { GoogleSheetsSpreadsheetNotFoundError } from "./error.js";
5
+ import { GoogleSheetsConsume } from "../client/index.js";
6
+ import { GoogleSheetsDriver } from "./driver.js";
7
+
8
+ export function createGoogleSheetsDataSource(options: GoogleSheetsDataSourceOptions): DataSource
9
+ {
10
+ let client: GoogleSheetsClient;
11
+
12
+ if (options.client !== undefined) client = options.client;
13
+ else
14
+ {
15
+ if (!('spreadsheetId' in options) || !('credentials' in options))
16
+ {
17
+ throw new GoogleSheetsSpreadsheetNotFoundError(
18
+ 'spreadsheetId and credentials are required when client is not provided.',
19
+ );
20
+ }
21
+
22
+ client = new GoogleSheetsConsume({
23
+ spreadsheetId: options.spreadsheetId,
24
+ credentials: options.credentials,
25
+ });
26
+ }
27
+
28
+ const originalCreate = DriverFactory.prototype.create;
29
+
30
+ DriverFactory.prototype.create = function (dataSource)
31
+ {
32
+ const type = dataSource.options.type as string;
33
+
34
+ if (type === 'google-sheets') return new GoogleSheetsDriver(dataSource, client);
35
+
36
+ return originalCreate.call(this, dataSource);
37
+ };
38
+
39
+ try
40
+ {
41
+ return new DataSource(options as unknown as DataSourceOptions);
42
+ }
43
+ finally
44
+ {
45
+ DriverFactory.prototype.create = originalCreate;
46
+ }
47
+ }