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,1297 @@
1
+ import type {
2
+ ObjectLiteral,
3
+ ReplicationMode,
4
+ Table, TableCheck,
5
+ TableColumn,
6
+ TableExclusion,
7
+ TableForeignKey,
8
+ TableIndex,
9
+ TableUnique,
10
+ View
11
+ } from "typeorm";
12
+ import { BaseQueryRunner } from "typeorm/query-runner/BaseQueryRunner.js";
13
+ import { Broadcaster } from "typeorm/subscriber/Broadcaster.js";
14
+ import type { IsolationLevel } from "typeorm/driver/types/IsolationLevel.js";
15
+ import type { ReadStream } from "typeorm/platform/PlatformTools.js";
16
+ import type { GoogleSheetsDriver } from "../driver.js";
17
+ import { GoogleSheetsQueryInterpreter } from "./interpreter.js";
18
+ import
19
+ {
20
+ GoogleSheetsDuplicatePrimaryKeyError,
21
+ GoogleSheetsGenaralError,
22
+ GoogleSheetsMissingPrimaryKeyError,
23
+ GoogleSheetsParseError,
24
+ GoogleSheetsUnsupportedOperationError
25
+ } from "../error.js";
26
+ import type { JoinCondition, JoinedRow, JoinOperand, JoinQuery } from "./types.js";
27
+ import type { GoogleSheetsRow } from "../types.js";
28
+
29
+ export class GoogleSheetsQueryRunner extends BaseQueryRunner
30
+ {
31
+ private readonly interpreter = new GoogleSheetsQueryInterpreter();
32
+ private distinctParameterValues = new Map<string, unknown[]>();
33
+
34
+ constructor(
35
+ private readonly driver: GoogleSheetsDriver,
36
+ mode: ReplicationMode,
37
+ )
38
+ {
39
+ super();
40
+
41
+ this.driver = driver;
42
+ this.dataSource = driver.dataSource;
43
+ this.broadcaster = new Broadcaster(this);
44
+ this.mode = mode;
45
+ }
46
+
47
+ protected async loadTables(_tablePaths?: string[]): Promise<Table[]>
48
+ {
49
+ return [];
50
+ }
51
+
52
+ protected async loadViews(_tablePaths?: string[]): Promise<View[]>
53
+ {
54
+ return [];
55
+ }
56
+
57
+ async connect(): Promise<any>
58
+ {
59
+ await this.driver.connect();
60
+
61
+ return this.driver.client;
62
+ }
63
+
64
+ async release(): Promise<void>
65
+ {
66
+ if (this.isReleased) return;
67
+
68
+ this.isReleased = true;
69
+ }
70
+
71
+ async query(query: string, parameters?: any[] | ObjectLiteral, useStructuredResult?: boolean): Promise<any>
72
+ {
73
+ try
74
+ {
75
+ if (this.isReleased) throw new GoogleSheetsGenaralError('QueryRunner already released.')
76
+
77
+ this.driver.dataSource.logger.logQuery(query, parameters, this);
78
+
79
+ const normalizedQuery = query.trim().toUpperCase();
80
+
81
+ if (normalizedQuery.startsWith('INSERT INTO')) return this.executeInsert(query, parameters, useStructuredResult);
82
+ if (normalizedQuery.startsWith('UPDATE')) return this.executeUpdate(query, parameters, useStructuredResult);
83
+ if (normalizedQuery.startsWith('DELETE FROM')) return this.executeDelete(query, parameters, useStructuredResult);
84
+ if (/\bWHERE\s+EXISTS\s*\(/i.test(query)) return this.executeExists(query, parameters, useStructuredResult);
85
+ if (/^SELECT\s+DISTINCT\s+"distinctAlias"\./i.test(query.trim()))
86
+ {
87
+ return this.executeDistinctPagination(query, parameters, useStructuredResult);
88
+ }
89
+
90
+ const from = this.interpreter.parseFrom(query);
91
+ const rows = await this.driver.client.getRows(from.table);
92
+ const joins = this.interpreter.parseJoins(query);
93
+
94
+ let joinedRows: JoinedRow[];
95
+
96
+ if (joins.length > 0) joinedRows = await this.executeJoins(rows, from.alias, joins, parameters);
97
+ else joinedRows = rows.map((row) => ({ [from.alias]: row }));
98
+
99
+ /**
100
+ * WHERE
101
+ */
102
+
103
+ const where = this.interpreter.parseWhereExpression(query);
104
+
105
+ let filteredRows = joinedRows;
106
+
107
+ if (where)
108
+ {
109
+ filteredRows =
110
+ joinedRows.filter(
111
+ (row) => this.interpreter.evaluateWhereExpression(where, (tableAlias, column) =>
112
+ {
113
+ const value =
114
+ row[tableAlias]?.[column];
115
+
116
+ const metadata =
117
+ this.getEntityMetadata(
118
+ tableAlias === from.alias
119
+ ? from.table
120
+ : tableAlias,
121
+ );
122
+
123
+ const columnMetadata =
124
+ metadata?.columns.find(
125
+ (item) => item.databaseName === column,
126
+ );
127
+
128
+ if (!columnMetadata)
129
+ {
130
+ return value;
131
+ }
132
+
133
+ return this.driver.prepareHydratedValue(
134
+ value,
135
+ columnMetadata,
136
+ );
137
+ },
138
+ (parameterName) =>
139
+ {
140
+ /**
141
+ * SQL numeric literal:
142
+ *
143
+ * IN (1)
144
+ * IN (10)
145
+ * IN (-1)
146
+ * IN (1.5)
147
+ */
148
+
149
+ if (/^-?\d+(?:\.\d+)?$/.test(parameterName)) return Number(parameterName);
150
+
151
+ /**
152
+ * SQL string literal:
153
+ *
154
+ * IN ('Basuni')
155
+ */
156
+
157
+ if (/^'.*'$/.test(parameterName)) return parameterName.slice(1, -1);
158
+
159
+ /**
160
+ * ORM parameter:
161
+ *
162
+ * :orm_param_0
163
+ */
164
+
165
+ const spreadParameterMatch = parameterName.match(/^:?\.\.\.(.+)$/);
166
+
167
+ if (spreadParameterMatch?.[1])
168
+ {
169
+ const values = this.distinctParameterValues.get(spreadParameterMatch[1]);
170
+
171
+ if (values !== undefined) return values;
172
+ }
173
+
174
+ const parameterIndex = Number(parameterName.match(/\d+$/)?.[0] ?? -1);
175
+
176
+ if (Array.isArray(parameters)) return parameters[parameterIndex];
177
+
178
+ /**
179
+ * Named parameters.
180
+ */
181
+
182
+ if (parameters && typeof parameters === 'object') return parameters[parameterName];
183
+
184
+ return undefined;
185
+ },
186
+ ),
187
+ );
188
+ }
189
+
190
+ /**
191
+ * AGGREGATE
192
+ */
193
+ const aggregates = this.interpreter.parseAggregates(query);
194
+
195
+ if (aggregates.length > 0)
196
+ {
197
+ const record: Record<string, unknown> = {};
198
+
199
+ for (const aggregate of aggregates)
200
+ {
201
+ record[aggregate.alias] = this.interpreter.evaluateAggregate(filteredRows, aggregate);
202
+ }
203
+
204
+ const result = {
205
+ raw: [record],
206
+ records: [record],
207
+ affected: undefined,
208
+ };
209
+
210
+ if (useStructuredResult) return result;
211
+
212
+ return result.raw;
213
+ }
214
+
215
+ /**
216
+ * SELECT
217
+ */
218
+
219
+ const columns = this.interpreter.parseSelectColumns(query);
220
+
221
+ let records = filteredRows
222
+ .map(
223
+ (row) =>
224
+ {
225
+ const record: Record<string, unknown> = {};
226
+
227
+ for (const column of columns)
228
+ {
229
+ /**
230
+ * Literal:
231
+ *
232
+ * 1 AS "count"
233
+ */
234
+ if (/^\d+$/.test(column.column))
235
+ {
236
+ record[column.alias] = Number(column.column);
237
+
238
+ continue;
239
+ }
240
+
241
+ record[column.alias] = row[column.tableAlias]?.[column.column];
242
+ }
243
+
244
+ return record;
245
+ },
246
+ );
247
+
248
+ /**
249
+ * ORDER BY
250
+ */
251
+ const orderBy = this.interpreter.parseOrderBy(query);
252
+
253
+ if (orderBy.length > 0)
254
+ {
255
+ records.sort((left, right) =>
256
+ {
257
+ for (const order of orderBy)
258
+ {
259
+ const key = `${order.tableAlias}_${order.column}`;
260
+ const leftValue = left[key];
261
+ const rightValue = right[key];
262
+
263
+ if (leftValue === rightValue) continue;
264
+ if (leftValue === undefined || leftValue === null) return order.direction === 'ASC' ? -1 : 1;
265
+ if (rightValue === undefined || rightValue === null) return order.direction === 'ASC' ? 1 : -1;
266
+
267
+ const comparison = leftValue < rightValue ? -1 : 1;
268
+
269
+ return order.direction === 'ASC' ? comparison : -comparison;
270
+ }
271
+
272
+ return 0;
273
+ });
274
+ }
275
+
276
+ /**
277
+ * LIMIT / OFFSET
278
+ */
279
+ const pagination = this.interpreter.parsePagination(query);
280
+
281
+ if (pagination.offset !== undefined) records = records.slice(pagination.offset);
282
+ if (pagination.limit !== undefined) records = records.slice(0, pagination.limit);
283
+
284
+ const result = {
285
+ raw: records,
286
+ records,
287
+ affected: undefined,
288
+ };
289
+
290
+ if (useStructuredResult) return result;
291
+
292
+ return result.raw;
293
+ } catch (error)
294
+ {
295
+ this.driver.dataSource.logger.logQueryError(
296
+ error instanceof Error ? error : String(error),
297
+ query,
298
+ parameters,
299
+ this,
300
+ );
301
+
302
+ throw error;
303
+ }
304
+ }
305
+
306
+ private extractSheetName(sql: string): string
307
+ {
308
+ const match = sql.match(/(?:FROM|INSERT\s+INTO|UPDATE|DELETE\s+FROM)\s+"([^"]+)"/i);
309
+
310
+ if (!match)
311
+ {
312
+ throw new GoogleSheetsGenaralError(`Unable to determine Google Sheets worksheet from query: ${sql}`)
313
+ }
314
+
315
+ return match[1]!;
316
+ }
317
+
318
+ private getEntityMetadata(tableName: string)
319
+ {
320
+ return this.connection.entityMetadatas.find(
321
+ (metadata) => metadata.tableName === tableName || metadata.name === tableName
322
+ );
323
+ }
324
+
325
+ private resolveParameter(value: string, parameters?: any[] | Record<string, any>): unknown
326
+ {
327
+ const parameterMatch = value.match(/^:(.+)$/);
328
+
329
+ if (!parameterMatch?.[1]) throw new GoogleSheetsGenaralError(`Unsupported parameter value: ${value}`);
330
+
331
+ const parameterName = parameterMatch[1];
332
+
333
+ if (Array.isArray(parameters))
334
+ {
335
+ const parameterIndex = parameterName.match(/^orm_param_(\d+)$/);
336
+
337
+ if (!parameterIndex?.[1]) throw new GoogleSheetsGenaralError(`Unable to resolve parameter: ${value}`);
338
+
339
+ return parameters[Number(parameterIndex[1])];
340
+ }
341
+
342
+ if (parameters) return parameters[parameterName];
343
+
344
+ return undefined;
345
+ }
346
+
347
+ private async assignIncrementGeneratedIds(sheetName: string, rows: GoogleSheetsRow[], columnName: string)
348
+ : Promise<void>
349
+ {
350
+ const existingRows = await this.driver.client.getRows(sheetName);
351
+
352
+ let nextId = existingRows.reduce((max, currentRow) =>
353
+ {
354
+ const value = Number(currentRow[columnName]);
355
+
356
+ return Number.isFinite(value) ? Math.max(max, value) : max;
357
+ }, 0) + 1;
358
+
359
+ for (const row of rows) row[columnName] = nextId++;
360
+ }
361
+
362
+ private assignUuidGeneratedIds(rows: GoogleSheetsRow[], columnName: string): void
363
+ {
364
+ for (const row of rows) row[columnName] = crypto.randomUUID();
365
+ }
366
+
367
+ private async executeInsert(query: string, parameters?: any[] | Record<string, any>, useStructuredResult = false)
368
+ : Promise<any>
369
+ {
370
+ const sheetName = this.extractSheetName(query);
371
+
372
+ const insert = this.interpreter.parseInsertQuery(query);
373
+
374
+ const rows: Record<string, unknown>[] = [];
375
+
376
+ for (const values of insert.values)
377
+ {
378
+ const row: Record<string, unknown> = {};
379
+
380
+ for (let index = 0; index < insert.columns.length; index++)
381
+ {
382
+ const column = insert.columns[index];
383
+ const value = values[index];
384
+
385
+ if (column === undefined || value === undefined)
386
+ {
387
+ throw new GoogleSheetsGenaralError('INSERT columns and values are inconsistent.');
388
+ }
389
+
390
+ if (value.trim().toUpperCase() === 'DEFAULT')
391
+ {
392
+ row[column] = undefined;
393
+
394
+ continue;
395
+ }
396
+
397
+ row[column] = this.resolveParameter(value, parameters);
398
+ }
399
+
400
+ rows.push(row);
401
+ }
402
+
403
+ const metadata = this.getEntityMetadata(sheetName);
404
+
405
+ if (metadata)
406
+ {
407
+ const now = new Date();
408
+
409
+ for (const row of rows)
410
+ {
411
+ // Resolve generated/default values first.
412
+ for (const column of metadata.columns)
413
+ {
414
+ if (row[column.databaseName] !== undefined) continue;
415
+
416
+ if (column.isCreateDate || column.isUpdateDate)
417
+ {
418
+ row[column.databaseName] = now;
419
+ continue;
420
+ }
421
+
422
+ if (column.default !== undefined)
423
+ {
424
+ row[column.databaseName] = typeof column.default === 'function'
425
+ ? column.default()
426
+ : column.default;
427
+ }
428
+ }
429
+
430
+ // Validate column constraints.
431
+ for (const column of metadata.columns)
432
+ {
433
+ const value = row[column.databaseName];
434
+
435
+ if (value !== undefined && value !== null) continue;
436
+
437
+ if (column.isPrimary && !column.isGenerated)
438
+ {
439
+ throw new GoogleSheetsMissingPrimaryKeyError();
440
+ }
441
+
442
+ if (column.isGenerated) continue;
443
+
444
+ if (column.isNullable) continue;
445
+
446
+ throw new GoogleSheetsGenaralError(`Column "${column.databaseName}" cannot be null.`);
447
+ }
448
+ }
449
+ }
450
+
451
+ const generatedColumn = metadata?.columns.find((column) => column.isPrimary && column.isGenerated);
452
+
453
+ const explicitPrimaryKeyRows = generatedColumn
454
+ ? rows.filter((row) => row[generatedColumn.databaseName] !== undefined)
455
+ : [];
456
+
457
+ if (generatedColumn)
458
+ {
459
+ const generationStrategy = generatedColumn.generationStrategy as
460
+ | 'increment'
461
+ | 'uuid'
462
+ | 'identity'
463
+ | 'rowid'
464
+ | undefined;
465
+
466
+ switch (generationStrategy)
467
+ {
468
+ case 'increment':
469
+ {
470
+ const generatedRows = rows.filter((row) => !explicitPrimaryKeyRows.includes(row));
471
+
472
+ await this.assignIncrementGeneratedIds(sheetName, generatedRows, generatedColumn.databaseName);
473
+
474
+ break;
475
+ }
476
+
477
+ case 'uuid':
478
+ {
479
+ const generatedRows = rows.filter((row) => !explicitPrimaryKeyRows.includes(row));
480
+
481
+ this.assignUuidGeneratedIds(generatedRows, generatedColumn.databaseName);
482
+
483
+ break;
484
+ }
485
+
486
+ case 'identity':
487
+ throw new GoogleSheetsGenaralError(
488
+ 'PrimaryGeneratedColumn("identity") is not supported by Google Sheets driver.'
489
+ );
490
+
491
+ case 'rowid':
492
+ throw new GoogleSheetsGenaralError(
493
+ 'PrimaryGeneratedColumn("rowid") is not supported by Google Sheets driver.'
494
+ );
495
+
496
+ default:
497
+ throw new GoogleSheetsGenaralError(
498
+ `Unsupported generation strategy: ${String(generatedColumn.generationStrategy)}`
499
+ );
500
+ }
501
+ }
502
+
503
+ if (explicitPrimaryKeyRows.length > 0)
504
+ {
505
+ const existingRows = await this.driver.client.getRows(sheetName);
506
+
507
+ for (const row of explicitPrimaryKeyRows)
508
+ {
509
+ const duplicate =
510
+ existingRows.some(
511
+ (existingRow) => existingRow[generatedColumn!.databaseName] === row[generatedColumn!.databaseName]
512
+ );
513
+
514
+ if (duplicate)
515
+ {
516
+ throw new GoogleSheetsDuplicatePrimaryKeyError();
517
+ }
518
+ }
519
+ }
520
+
521
+ await this.driver.client.insertRows(sheetName, rows);
522
+
523
+ const result = {
524
+ raw: rows,
525
+ records: rows,
526
+ affected: rows.length,
527
+ };
528
+
529
+ if (useStructuredResult) return result;
530
+
531
+ return result.raw;
532
+ }
533
+
534
+ private async executeUpdate(query: string, parameters?: any[] | Record<string, any>, useStructuredResult = false)
535
+ : Promise<any>
536
+ {
537
+ const update = this.interpreter.parseUpdateQuery(query);
538
+
539
+ const updateValues: Record<string, any> = {};
540
+
541
+ for (const assignment of update.assignments)
542
+ {
543
+ if (assignment.value.toUpperCase() === 'CURRENT_TIMESTAMP')
544
+ {
545
+ updateValues[assignment.column] = new Date();
546
+
547
+ continue;
548
+ }
549
+
550
+ if (assignment.value.toUpperCase() === 'NULL')
551
+ {
552
+ updateValues[assignment.column] = null;
553
+
554
+ continue;
555
+ }
556
+
557
+ updateValues[assignment.column] = this.resolveParameter(assignment.value, parameters);
558
+ }
559
+
560
+
561
+ const affected = await this.driver.client.updateRows(update.table, (row) =>
562
+ {
563
+ const result = this.interpreter.evaluateWhereExpression(
564
+ update.where,
565
+ (_tableAlias, column) => row[column],
566
+ (parameter) => this.resolveParameter(parameter, parameters)
567
+ );
568
+
569
+ return result;
570
+ },
571
+ updateValues,
572
+ );
573
+
574
+ const result = {
575
+ raw: [],
576
+ records: [],
577
+ affected,
578
+ };
579
+
580
+ if (useStructuredResult) return result;
581
+
582
+ return result.raw;
583
+ }
584
+
585
+ private async executeDelete(query: string, parameters?: any[] | Record<string, any>, useStructuredResult = false)
586
+ : Promise<any>
587
+ {
588
+ const deletion = this.interpreter.parseDeleteQuery(query);
589
+
590
+ const affected = await this.driver.client.deleteRows(deletion.table,
591
+ (row) =>
592
+ this.interpreter.evaluateWhereExpression(deletion.where,
593
+ (_tableAlias, column) => row[column],
594
+ (parameter) => this.resolveParameter(parameter, parameters)
595
+ ),
596
+ );
597
+
598
+ const result = {
599
+ raw: [],
600
+ records: [],
601
+ affected,
602
+ };
603
+
604
+ if (useStructuredResult) return result;
605
+
606
+ return result.raw;
607
+ }
608
+
609
+ private async executeExists(query: string, parameters?: any[] | Record<string, any>, useStructuredResult = false)
610
+ : Promise<any>
611
+ {
612
+ const existsMatch = query.match(
613
+ /WHERE\s+EXISTS\s*\(\s*SELECT\s+1\s+FROM\s+"([^"]+)"\s+"([^"]+)"\s+WHERE\s+\(\((.+?)\)\)\s*\)\s+LIMIT\s+1/i,
614
+ );
615
+
616
+ if (!existsMatch?.[1] || !existsMatch[2] || !existsMatch[3])
617
+ {
618
+ throw new GoogleSheetsGenaralError(`Unable to parse EXISTS query: ${query}`);
619
+ }
620
+
621
+ const sheetName = existsMatch[1];
622
+ const alias = existsMatch[2];
623
+ const whereQuery = existsMatch[3];
624
+
625
+ const rows = await this.driver.client.getRows(sheetName);
626
+
627
+ const where = this.interpreter.parseWhereExpression(`SELECT * FROM "${sheetName}" "${alias}" WHERE ${whereQuery}`);
628
+
629
+ if (!where) throw new GoogleSheetsParseError(`Unable to parse EXISTS WHERE expression: ${whereQuery}`)
630
+
631
+ const exists = rows.some((row) =>
632
+ this.interpreter.evaluateWhereExpression(where,
633
+ (column) => row[column],
634
+ (parameterName) =>
635
+ {
636
+ const parameterIndex = Number(parameterName.match(/\d+$/)?.[0] ?? -1);
637
+
638
+ return Array.isArray(parameters) ? parameters[parameterIndex] : undefined;
639
+ })
640
+ );
641
+
642
+ const records = exists ? [{ row_exists: 1 }] : [];
643
+
644
+ const result = {
645
+ raw: records,
646
+ records,
647
+ affected: undefined,
648
+ };
649
+
650
+ if (useStructuredResult) return result;
651
+
652
+ return result.raw;
653
+ }
654
+
655
+ private async executeJoins(
656
+ baseRows: GoogleSheetsRow[],
657
+ baseAlias: string,
658
+ joins: JoinQuery[],
659
+ parameters?: any[] | Record<string, any>,
660
+ ): Promise<JoinedRow[]>
661
+ {
662
+ let joinedRows: JoinedRow[] = baseRows.map((row) => ({ [baseAlias]: row }));
663
+
664
+ for (const join of joins)
665
+ {
666
+ const joinedData = await this.driver.client.getRows(join.table);
667
+
668
+ joinedRows = joinedRows.flatMap((joinedRow) =>
669
+ {
670
+ const matches = joinedData.filter(
671
+ (candidate) => join.conditions.every(
672
+ (condition) => this.evaluateJoinCondition(condition, joinedRow, candidate, join.alias, parameters)
673
+ ),
674
+ );
675
+
676
+ if (matches.length === 0)
677
+ {
678
+ if (join.type === 'LEFT') return [{ ...joinedRow, [join.alias]: undefined }];
679
+
680
+ return [];
681
+ }
682
+
683
+ return matches.map((candidate) => ({ ...joinedRow, [join.alias]: candidate }));
684
+ });
685
+ }
686
+
687
+ return joinedRows;
688
+ }
689
+
690
+ private evaluateJoinCondition(
691
+ condition: JoinCondition,
692
+ joinedRow: JoinedRow,
693
+ candidate: GoogleSheetsRow,
694
+ joinedAlias: string,
695
+ parameters?: any[] | Record<string, any>,
696
+ ): boolean
697
+ {
698
+ const resolveOperand = (operand: JoinOperand): unknown =>
699
+ {
700
+ if (operand.type === 'parameter') return this.resolveParameter(operand.value, parameters);
701
+ if (operand.tableAlias === joinedAlias) return candidate[operand.column];
702
+
703
+ return joinedRow[operand.tableAlias]?.[operand.column];
704
+ };
705
+
706
+ const left = resolveOperand(condition.left);
707
+
708
+ if (condition.operator === 'IS NULL') return left === null || left === undefined;
709
+
710
+ if (condition.operator === 'IS NOT NULL') return left !== null && left !== undefined;
711
+
712
+ const right = resolveOperand(condition.right!);
713
+
714
+ return left === right;
715
+ }
716
+
717
+ private async executeDistinctPagination(
718
+ query: string, parameters?: any[] | Record<string, any>, useStructuredResult = false)
719
+ : Promise<any>
720
+ {
721
+ /**
722
+ * Extract inner query:
723
+ *
724
+ * FROM (
725
+ * SELECT ...
726
+ * ) "distinctAlias"
727
+ */
728
+
729
+ const subqueryMatch = query.match(/\bFROM\s*\((SELECT[\s\S]+)\)\s+"distinctAlias"/i);
730
+
731
+ if (!subqueryMatch?.[1]) throw new GoogleSheetsParseError('Unable to parse DISTINCT pagination subquery.');
732
+
733
+ const innerQuery = subqueryMatch[1];
734
+
735
+ /**
736
+ * Execute inner query normally.
737
+ *
738
+ * Example result:
739
+ *
740
+ * [
741
+ * {
742
+ * user_id: 1,
743
+ * user_name: 'Basuni',
744
+ * post_id: 1,
745
+ * },
746
+ * {
747
+ * user_id: 1,
748
+ * user_name: 'Basuni',
749
+ * post_id: 2,
750
+ * },
751
+ * {
752
+ * user_id: 2,
753
+ * user_name: 'Budi',
754
+ * post_id: 3,
755
+ * },
756
+ * ]
757
+ */
758
+
759
+ const innerResult = await this.query(innerQuery, parameters, true);
760
+ const innerRows = innerResult.records as Record<string, unknown>[];
761
+
762
+ /**
763
+ * Parse outer SELECT.
764
+ *
765
+ * Example:
766
+ *
767
+ * SELECT DISTINCT
768
+ * "distinctAlias"."user_id" AS "ids_user_id",
769
+ * "distinctAlias"."user_id",
770
+ * "distinctAlias"."post_id"
771
+ */
772
+
773
+ const selectMatch = query.match(/^SELECT\s+DISTINCT\s+([\s\S]+?)\s+FROM\s*\(/i);
774
+
775
+ if (!selectMatch?.[1]) throw new GoogleSheetsParseError('Unable to parse DISTINCT pagination SELECT.');
776
+
777
+ const selectExpressions = selectMatch[1].split(',').map((expression) => expression.trim());
778
+
779
+ /**
780
+ * Parse selected columns.
781
+ */
782
+
783
+ const selectedColumns = selectExpressions.map((expression) =>
784
+ {
785
+ const match = expression.match(/^"distinctAlias"\."([^"]+)"(?:\s+AS\s+"([^"]+)")?$/i);
786
+
787
+ if (!match?.[1])
788
+ {
789
+ throw new GoogleSheetsParseError(`Unable to parse DISTINCT pagination expression: ${expression}`);
790
+ }
791
+
792
+ return {
793
+ column: match[1],
794
+ alias: match[2] ?? match[1],
795
+ };
796
+ });
797
+
798
+ /**
799
+ * TypeORM names the primary ID column:
800
+ *
801
+ * ids_<column>
802
+ *
803
+ * Example:
804
+ *
805
+ * user_id
806
+ * ↓
807
+ * ids_user_id
808
+ *
809
+ * Find the entity ID column from the outer SELECT.
810
+ */
811
+
812
+ const primaryColumn = selectedColumns.find((selected) => selected.alias.startsWith('ids_'));
813
+
814
+ if (!primaryColumn) throw new GoogleSheetsParseError('Unable to determine DISTINCT pagination primary column.');
815
+
816
+ const from = this.interpreter.parseFrom(query);
817
+
818
+ const metadata = this.getEntityMetadata(from.table);
819
+
820
+ const primaryColumnMetadata =
821
+ metadata?.columns.find(
822
+ (column) => column.databaseName === primaryColumn.column.replace(`${from.alias}_`, ''),
823
+ );
824
+
825
+ /**
826
+ * Convert inner rows into outer SELECT records.
827
+ *
828
+ * Example:
829
+ *
830
+ * inner row:
831
+ *
832
+ * {
833
+ * user_id: 1,
834
+ * post_id: 1
835
+ * }
836
+ *
837
+ * becomes:
838
+ *
839
+ * {
840
+ * ids_user_id: 1,
841
+ * user_id: 1,
842
+ * post_id: 1
843
+ * }
844
+ */
845
+
846
+ const records = innerRows.map((row) =>
847
+ {
848
+ const record: Record<string, unknown> = {};
849
+
850
+ for (const selected of selectedColumns) record[selected.alias] = row[selected.column];
851
+
852
+ return record;
853
+ });
854
+
855
+ /**
856
+ * DISTINCT by primary/entity ID.
857
+ *
858
+ * IMPORTANT:
859
+ *
860
+ * Do NOT use the whole record here.
861
+ *
862
+ * With:
863
+ *
864
+ * user post
865
+ * 1 1
866
+ * 1 2
867
+ * 2 3
868
+ *
869
+ * DISTINCT must produce:
870
+ *
871
+ * 1
872
+ * 2
873
+ *
874
+ * rather than treating:
875
+ *
876
+ * (1, 1)
877
+ * (1, 2)
878
+ *
879
+ * as two separate entities.
880
+ */
881
+
882
+ const distinctRecords: Record<string, unknown>[] = [];
883
+ const seen = new Set<string>();
884
+
885
+ for (const record of records)
886
+ {
887
+ const primaryValue = record[primaryColumn.alias];
888
+ const key = JSON.stringify(primaryValue);
889
+
890
+ if (seen.has(key)) continue;
891
+
892
+ seen.add(key);
893
+ distinctRecords.push(record);
894
+ }
895
+
896
+ const distinctValues = distinctRecords.map(
897
+ (record) =>
898
+ {
899
+ const value = record[primaryColumn.alias];
900
+
901
+ if (!primaryColumnMetadata) return value;
902
+
903
+ return this.driver.prepareHydratedValue(value, primaryColumnMetadata);
904
+ },
905
+ );
906
+
907
+ this.distinctParameterValues.set('orm_distinct_ids', distinctValues);
908
+
909
+ /**
910
+ * ORDER BY
911
+ *
912
+ * Example:
913
+ *
914
+ * ORDER BY
915
+ * "distinctAlias"."user_id" ASC,
916
+ * "distinctAlias"."post_id" ASC,
917
+ * "user_id" ASC
918
+ */
919
+
920
+ const orderByMatch = query.match(/\bORDER\s+BY\s+([\s\S]+?)(?:\s+LIMIT\s+\d+|\s+OFFSET\s+\d+|$)/i);
921
+
922
+ if (orderByMatch?.[1])
923
+ {
924
+ const expressions = orderByMatch[1].split(',').map((expression) => expression.trim());
925
+
926
+ distinctRecords.sort((left, right) =>
927
+ {
928
+ for (const expression of expressions)
929
+ {
930
+ /**
931
+ * Qualified:
932
+ *
933
+ * "distinctAlias"."user_id" ASC
934
+ */
935
+
936
+ const qualifiedMatch = expression.match(/^"distinctAlias"\."([^"]+)"(?:\s+(ASC|DESC))?$/i);
937
+
938
+ /**
939
+ * Unqualified:
940
+ *
941
+ * "user_id" ASC
942
+ */
943
+
944
+ const unqualifiedMatch = expression.match(/^"([^"]+)"(?:\s+(ASC|DESC))?$/i);
945
+ const column = qualifiedMatch?.[1] ?? unqualifiedMatch?.[1];
946
+
947
+ if (!column) continue;
948
+
949
+ const direction = (qualifiedMatch?.[2] ?? unqualifiedMatch?.[2] ?? 'ASC').toUpperCase();
950
+
951
+ /**
952
+ * Outer SELECT may have either:
953
+ *
954
+ * user_id
955
+ *
956
+ * or:
957
+ *
958
+ * ids_user_id
959
+ */
960
+
961
+ const leftValue = left[column] ?? left[`ids_${column}`];
962
+ const rightValue = right[column] ?? right[`ids_${column}`];
963
+
964
+ if (leftValue === rightValue) continue;
965
+ if (leftValue === undefined || leftValue === null) return direction === 'ASC' ? -1 : 1;
966
+ if (rightValue === undefined || rightValue === null) return direction === 'ASC' ? 1 : -1;
967
+
968
+ const comparison = leftValue < rightValue ? -1 : 1;
969
+
970
+ return direction === 'ASC' ? comparison : -comparison;
971
+ }
972
+
973
+ return 0;
974
+ });
975
+ }
976
+
977
+ /**
978
+ * OFFSET
979
+ */
980
+
981
+ const offsetMatch = query.match(/\bOFFSET\s+(\d+)/i);
982
+
983
+ if (offsetMatch?.[1]) distinctRecords.splice(0, Number(offsetMatch[1]));
984
+
985
+ /**
986
+ * LIMIT
987
+ */
988
+
989
+ const limitMatch = query.match(/\bLIMIT\s+(\d+)/i);
990
+
991
+ if (limitMatch?.[1]) distinctRecords.splice(Number(limitMatch[1]));
992
+
993
+ const result = {
994
+ raw: distinctRecords,
995
+ records: distinctRecords,
996
+ affected: undefined,
997
+ };
998
+
999
+ if (useStructuredResult) return result;
1000
+
1001
+ return result.raw;
1002
+ }
1003
+
1004
+ async stream(_query: string, parameters?: any[], _onEnd?: Function, _onError?: Function): Promise<ReadStream>
1005
+ {
1006
+ return this.unsupported('stream')
1007
+ }
1008
+
1009
+ async startTransaction(_isolationLevel?: IsolationLevel): Promise<void>
1010
+ {
1011
+ return this.unsupported('transactions')
1012
+ }
1013
+
1014
+ async commitTransaction(): Promise<void>
1015
+ {
1016
+ return this.unsupported('transactions')
1017
+ }
1018
+
1019
+ async rollbackTransaction(): Promise<void>
1020
+ {
1021
+ return this.unsupported('transactions')
1022
+ }
1023
+
1024
+ async clearDatabase(_database?: string): Promise<void>
1025
+ {
1026
+ return this.unsupported('clear-database')
1027
+ }
1028
+
1029
+ async getDatabases(): Promise<string[]>
1030
+ {
1031
+ return [];
1032
+ }
1033
+
1034
+ async getSchemas(_database?: string): Promise<string[]>
1035
+ {
1036
+ return [];
1037
+ }
1038
+
1039
+ async hasDatabase(_database: string): Promise<boolean>
1040
+ {
1041
+ return false;
1042
+ }
1043
+
1044
+ async getCurrentDatabase(): Promise<string | undefined>
1045
+ {
1046
+ return undefined;
1047
+ }
1048
+
1049
+ async hasSchema(_schema: string): Promise<boolean>
1050
+ {
1051
+ return false;
1052
+ }
1053
+
1054
+ async getCurrentSchema(): Promise<string | undefined>
1055
+ {
1056
+ return undefined;
1057
+ }
1058
+
1059
+ async hasTable(_table: Table | string): Promise<boolean>
1060
+ {
1061
+ return false;
1062
+ }
1063
+
1064
+ async hasColumn(_table: Table | string, _columnName: string): Promise<boolean>
1065
+ {
1066
+ return false;
1067
+ }
1068
+
1069
+ async createDatabase(_database: string, _ifNotExists?: boolean): Promise<void>
1070
+ {
1071
+ return this.unsupported('create-database');
1072
+ }
1073
+
1074
+ async dropDatabase(_database: string, _ifExists?: boolean): Promise<void>
1075
+ {
1076
+ return this.unsupported('drop-databse');
1077
+ }
1078
+
1079
+ async createSchema(_schemaPath: string, _ifNotExists?: boolean): Promise<void>
1080
+ {
1081
+ return this.unsupported('create-schema');
1082
+ }
1083
+
1084
+ async dropSchema(
1085
+ _schemaPath: string,
1086
+ _ifExists?: boolean,
1087
+ _isCascade?: boolean,
1088
+ ): Promise<void>
1089
+ {
1090
+ return this.unsupported('drop-schema');
1091
+ }
1092
+
1093
+ async createTable(
1094
+ _table: Table,
1095
+ _ifNotExists?: boolean,
1096
+ _createForeignKeys?: boolean,
1097
+ _createIndices?: boolean,
1098
+ ): Promise<void>
1099
+ {
1100
+ return this.unsupported('create-table');
1101
+ }
1102
+
1103
+ async dropTable(_table: Table | string, _ifExists?: boolean, _dropForeignKeys?: boolean, _dropIndices?: boolean)
1104
+ : Promise<void>
1105
+ {
1106
+ return this.unsupported('drop-table');
1107
+ }
1108
+
1109
+ async createView(_view: View, _syncWithMetadata?: boolean, _oldView?: View): Promise<void>
1110
+ {
1111
+ return this.unsupported('create-view');
1112
+ }
1113
+
1114
+ async dropView(_view: View | string, _ifExists?: boolean): Promise<void>
1115
+ {
1116
+ return this.unsupported('drop-view');
1117
+ }
1118
+
1119
+ async renameTable(_oldTableOrName: Table | string, _newTableName: string): Promise<void>
1120
+ {
1121
+ return this.unsupported('rename-table');
1122
+ }
1123
+
1124
+ async changeTableComment(_tableOrName: Table | string, _comment?: string): Promise<void>
1125
+ {
1126
+ return this.unsupported('change=table-comment');
1127
+ }
1128
+
1129
+ async addColumn(_table: Table | string, _column: TableColumn): Promise<void>
1130
+ {
1131
+ return this.unsupported('add-column');
1132
+ }
1133
+
1134
+ async addColumns(_table: Table | string, _columns: TableColumn[]): Promise<void>
1135
+ {
1136
+ return this.unsupported('add-columns');
1137
+ }
1138
+
1139
+ async renameColumn(_table: Table | string, _oldColumnOrName: TableColumn | string, _newColumnOrName: TableColumn | string)
1140
+ : Promise<void>
1141
+ {
1142
+ return this.unsupported('rename-column');
1143
+ }
1144
+
1145
+ async changeColumn(_table: Table | string, _oldColumn: TableColumn | string, _newColumn: TableColumn): Promise<void>
1146
+ {
1147
+ return this.unsupported('change-column');
1148
+ }
1149
+
1150
+ async changeColumns(_table: Table | string, _changedColumns: { oldColumn: TableColumn; newColumn: TableColumn; }[])
1151
+ : Promise<void>
1152
+ {
1153
+ return this.unsupported('change-columns');
1154
+ }
1155
+
1156
+ async dropColumn(_table: Table | string, _column: TableColumn | string, _ifExists?: boolean): Promise<void>
1157
+ {
1158
+ return this.unsupported('drop-column');
1159
+ }
1160
+
1161
+ async dropColumns(_table: Table | string, _columns: TableColumn[] | string[], _ifExists?: boolean): Promise<void>
1162
+ {
1163
+ return this.unsupported('drop-columns');
1164
+ }
1165
+
1166
+ async createPrimaryKey(_table: Table | string, _columnNames: string[], _constraintName?: string): Promise<void>
1167
+ {
1168
+ return this.unsupported('create=primary-key');
1169
+ }
1170
+
1171
+ async updatePrimaryKeys(_table: Table | string, _columns: TableColumn[]): Promise<void>
1172
+ {
1173
+ return this.unsupported('update-primary-keys');
1174
+ }
1175
+
1176
+ async dropPrimaryKey(_table: Table | string, _constraintName?: string, _ifExists?: boolean): Promise<void>
1177
+ {
1178
+ return this.unsupported('drop=-pimary-key');
1179
+ }
1180
+
1181
+ async createUniqueConstraint(_table: Table | string, _uniqueConstraint: TableUnique): Promise<void>
1182
+ {
1183
+ return this.unsupported('create-unique-constraint');
1184
+ }
1185
+
1186
+ async createUniqueConstraints(_table: Table | string, _uniqueConstraints: TableUnique[]): Promise<void>
1187
+ {
1188
+ return this.unsupported('createunique-constraints');
1189
+ }
1190
+
1191
+ async dropUniqueConstraint(_table: Table | string, _uniqueOrName: TableUnique | string, _ifExists?: boolean)
1192
+ : Promise<void>
1193
+ {
1194
+ return this.unsupported('drop-unique-constraint');
1195
+ }
1196
+
1197
+ async dropUniqueConstraints(_table: Table | string, _uniqueConstraints: TableUnique[], _ifExists?: boolean,)
1198
+ : Promise<void>
1199
+ {
1200
+ return this.unsupported('drop-unique-constraints');
1201
+ }
1202
+
1203
+ async createCheckConstraint(_table: Table | string, _checkConstraint: TableCheck): Promise<void>
1204
+ {
1205
+ return this.unsupported('create-check-constraint');
1206
+ }
1207
+
1208
+ async createCheckConstraints(_table: Table | string, _checkConstraints: TableCheck[]): Promise<void>
1209
+ {
1210
+ return this.unsupported('create-check-constraints');
1211
+ }
1212
+
1213
+ async dropCheckConstraint(_table: Table | string, _checkOrName: TableCheck | string, _ifExists?: boolean)
1214
+ : Promise<void>
1215
+ {
1216
+ return this.unsupported('drop-check-constraint');
1217
+ }
1218
+
1219
+ async dropCheckConstraints(_table: Table | string, _checkConstraints: TableCheck[], _ifExists?: boolean)
1220
+ : Promise<void>
1221
+ {
1222
+ return this.unsupported('drop-check-constraints');
1223
+ }
1224
+
1225
+ async createExclusionConstraint(_table: Table | string, _exclusionConstraint: TableExclusion): Promise<void>
1226
+ {
1227
+ return this.unsupported('create-exclusion-constraint');
1228
+ }
1229
+
1230
+ async createExclusionConstraints(_table: Table | string, _exclusionConstraints: TableExclusion[]): Promise<void>
1231
+ {
1232
+ return this.unsupported('create-exclusion-constraints');
1233
+ }
1234
+
1235
+ async dropExclusionConstraint(_table: Table | string, _exclusionOrName: TableExclusion | string, _ifExists?: boolean)
1236
+ : Promise<void>
1237
+ {
1238
+ return this.unsupported('drop-exclusion-constraint');
1239
+ }
1240
+
1241
+ async dropExclusionConstraints(_table: Table | string, _exclusionConstraints: TableExclusion[], _ifExists?: boolean)
1242
+ : Promise<void>
1243
+ {
1244
+ return this.unsupported('drop-exclusion-constraints');
1245
+ }
1246
+
1247
+ async createForeignKey(_table: Table | string, _foreignKey: TableForeignKey): Promise<void>
1248
+ {
1249
+ return this.unsupported('create-foreign-key');
1250
+ }
1251
+
1252
+ async createForeignKeys(_table: Table | string, _foreignKeys: TableForeignKey[]): Promise<void>
1253
+ {
1254
+ return this.unsupported('create-foreign-keys');
1255
+ }
1256
+
1257
+ async dropForeignKey(_table: Table | string, _foreignKeyOrName: TableForeignKey | string, _ifExists?: boolean)
1258
+ : Promise<void>
1259
+ {
1260
+ return this.unsupported('drop-foreign-key');
1261
+ }
1262
+
1263
+ async dropForeignKeys(_table: Table | string, _foreignKeys: TableForeignKey[], _ifExists?: boolean): Promise<void>
1264
+ {
1265
+ return this.unsupported('drop-foreign-keys');
1266
+ }
1267
+
1268
+ async createIndex(_table: Table | string, _index: TableIndex): Promise<void>
1269
+ {
1270
+ return this.unsupported('create-index');
1271
+ }
1272
+
1273
+ async createIndices(_table: Table | string, _indices: TableIndex[]): Promise<void>
1274
+ {
1275
+ return this.unsupported('create-indeices');
1276
+ }
1277
+
1278
+ async dropIndex(_table: Table | string, _index: TableIndex | string, _ifExists?: boolean): Promise<void>
1279
+ {
1280
+ return this.unsupported('drop-index');
1281
+ }
1282
+
1283
+ async dropIndices(_table: Table | string, _indices: TableIndex[], _ifExists?: boolean): Promise<void>
1284
+ {
1285
+ return this.unsupported('drop-indices');
1286
+ }
1287
+
1288
+ async clearTable(_tableName: string, _options?: { cascade?: boolean }): Promise<void>
1289
+ {
1290
+ return this.unsupported('clear-table');
1291
+ }
1292
+
1293
+ private unsupported(operation: string): never
1294
+ {
1295
+ throw new GoogleSheetsUnsupportedOperationError(operation);
1296
+ }
1297
+ }