@objectql/driver-excel 0.2.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.
package/src/index.ts ADDED
@@ -0,0 +1,960 @@
1
+ /**
2
+ * Excel Driver for ObjectQL (Production-Ready)
3
+ *
4
+ * A driver for ObjectQL that reads and writes data from Excel (.xlsx) files.
5
+ * Each worksheet in the Excel file represents an object type, with the first row
6
+ * containing column headers (field names) and subsequent rows containing data.
7
+ *
8
+ * ✅ Features:
9
+ * - Read data from Excel files
10
+ * - Write data back to Excel files
11
+ * - Multiple sheets support (one sheet per object type)
12
+ * - Full CRUD operations
13
+ * - Query support (filters, sorting, pagination)
14
+ * - Automatic data type handling
15
+ * - Secure: Uses ExcelJS (no known vulnerabilities)
16
+ *
17
+ * Use Cases:
18
+ * - Import/export data from Excel spreadsheets
19
+ * - Use Excel as a simple database for prototyping
20
+ * - Data migration from Excel to other databases
21
+ * - Generate reports in Excel format
22
+ */
23
+
24
+ import { Driver, ObjectQLError } from '@objectql/types';
25
+ import * as ExcelJS from 'exceljs';
26
+ import * as fs from 'fs';
27
+ import * as path from 'path';
28
+
29
+ /**
30
+ * File storage mode for the Excel driver.
31
+ */
32
+ export type FileStorageMode = 'single-file' | 'file-per-object';
33
+
34
+ /**
35
+ * Configuration options for the Excel driver.
36
+ */
37
+ export interface ExcelDriverConfig {
38
+ /**
39
+ * Path to the Excel file or directory.
40
+ * - In 'single-file' mode: Path to a single .xlsx file
41
+ * - In 'file-per-object' mode: Path to a directory where object files are stored
42
+ */
43
+ filePath: string;
44
+
45
+ /**
46
+ * File storage mode (default: 'single-file')
47
+ * - 'single-file': All object types stored as worksheets in one Excel file
48
+ * - 'file-per-object': Each object type stored in a separate Excel file
49
+ */
50
+ fileStorageMode?: FileStorageMode;
51
+
52
+ /** Optional: Auto-save changes to file (default: true) */
53
+ autoSave?: boolean;
54
+ /** Optional: Create file if it doesn't exist (default: true) */
55
+ createIfMissing?: boolean;
56
+ /** Optional: Enable strict mode (throw on missing objects) */
57
+ strictMode?: boolean;
58
+ }
59
+
60
+ /**
61
+ * Excel Driver Implementation
62
+ *
63
+ * Stores ObjectQL documents in Excel worksheets. Each object type is stored
64
+ * in a separate worksheet, with the first row containing column headers.
65
+ *
66
+ * Uses ExcelJS library for secure Excel file operations.
67
+ */
68
+ export class ExcelDriver implements Driver {
69
+ private config: ExcelDriverConfig;
70
+ private workbook!: ExcelJS.Workbook;
71
+ private workbooks: Map<string, ExcelJS.Workbook>; // For file-per-object mode
72
+ private data: Map<string, any[]>;
73
+ private idCounters: Map<string, number>;
74
+ private filePath: string;
75
+ private fileStorageMode: FileStorageMode;
76
+
77
+ constructor(config: ExcelDriverConfig) {
78
+ this.config = {
79
+ autoSave: true,
80
+ createIfMissing: true,
81
+ strictMode: false,
82
+ fileStorageMode: 'single-file',
83
+ ...config
84
+ };
85
+
86
+ this.filePath = path.resolve(config.filePath);
87
+ this.fileStorageMode = this.config.fileStorageMode!;
88
+ this.data = new Map<string, any[]>();
89
+ this.idCounters = new Map<string, number>();
90
+ this.workbooks = new Map<string, ExcelJS.Workbook>();
91
+
92
+ // Initialize workbook for single-file mode
93
+ if (this.fileStorageMode === 'single-file') {
94
+ this.workbook = new ExcelJS.Workbook();
95
+ }
96
+
97
+ // Note: Actual file loading happens in init()
98
+ // Call init() after construction or use the async create() factory method
99
+ }
100
+
101
+ /**
102
+ * Initialize the driver by loading the workbook from file.
103
+ * This must be called after construction before using the driver.
104
+ */
105
+ async init(): Promise<void> {
106
+ await this.loadWorkbook();
107
+ }
108
+
109
+ /**
110
+ * Factory method to create and initialize the driver.
111
+ */
112
+ static async create(config: ExcelDriverConfig): Promise<ExcelDriver> {
113
+ const driver = new ExcelDriver(config);
114
+ await driver.init();
115
+ return driver;
116
+ }
117
+
118
+ /**
119
+ * Load workbook from file or create a new one.
120
+ * Handles both single-file and file-per-object modes.
121
+ */
122
+ private async loadWorkbook(): Promise<void> {
123
+ if (this.fileStorageMode === 'single-file') {
124
+ await this.loadSingleFileWorkbook();
125
+ } else {
126
+ await this.loadFilePerObjectWorkbooks();
127
+ }
128
+ }
129
+
130
+ /**
131
+ * Load workbook in single-file mode (all objects in one Excel file).
132
+ */
133
+ private async loadSingleFileWorkbook(): Promise<void> {
134
+ this.workbook = new ExcelJS.Workbook();
135
+
136
+ if (fs.existsSync(this.filePath)) {
137
+ try {
138
+ await this.workbook.xlsx.readFile(this.filePath);
139
+ this.loadDataFromWorkbook();
140
+ } catch (error) {
141
+ const errorMessage = (error as Error).message;
142
+
143
+ // Provide helpful error messages for common issues
144
+ let detailedMessage = `Failed to read Excel file: ${this.filePath}`;
145
+ if (errorMessage.includes('corrupted') || errorMessage.includes('invalid')) {
146
+ detailedMessage += ' - File may be corrupted or not a valid .xlsx file';
147
+ } else if (errorMessage.includes('permission') || errorMessage.includes('EACCES')) {
148
+ detailedMessage += ' - Permission denied. Check file permissions.';
149
+ } else if (errorMessage.includes('EBUSY')) {
150
+ detailedMessage += ' - File is locked by another process. Close it and try again.';
151
+ }
152
+
153
+ throw new ObjectQLError({
154
+ code: 'FILE_READ_ERROR',
155
+ message: detailedMessage,
156
+ details: {
157
+ filePath: this.filePath,
158
+ error: errorMessage
159
+ }
160
+ });
161
+ }
162
+ } else if (this.config.createIfMissing) {
163
+ // Create new empty workbook
164
+ await this.saveWorkbook();
165
+ } else {
166
+ throw new ObjectQLError({
167
+ code: 'FILE_NOT_FOUND',
168
+ message: `Excel file not found: ${this.filePath}`,
169
+ details: { filePath: this.filePath }
170
+ });
171
+ }
172
+ }
173
+
174
+ /**
175
+ * Load workbooks in file-per-object mode (each object in separate Excel file).
176
+ */
177
+ private async loadFilePerObjectWorkbooks(): Promise<void> {
178
+ // Ensure directory exists
179
+ if (!fs.existsSync(this.filePath)) {
180
+ if (this.config.createIfMissing) {
181
+ fs.mkdirSync(this.filePath, { recursive: true });
182
+ } else {
183
+ throw new ObjectQLError({
184
+ code: 'DIRECTORY_NOT_FOUND',
185
+ message: `Directory not found: ${this.filePath}`,
186
+ details: { filePath: this.filePath }
187
+ });
188
+ }
189
+ }
190
+
191
+ // Check if it's actually a directory
192
+ const stats = fs.statSync(this.filePath);
193
+ if (!stats.isDirectory()) {
194
+ throw new ObjectQLError({
195
+ code: 'INVALID_PATH',
196
+ message: `Path must be a directory in file-per-object mode: ${this.filePath}`,
197
+ details: { filePath: this.filePath }
198
+ });
199
+ }
200
+
201
+ // Load all existing .xlsx files in the directory
202
+ const files = fs.readdirSync(this.filePath);
203
+ for (const file of files) {
204
+ if (file.endsWith('.xlsx') && !file.startsWith('~$')) {
205
+ const objectName = file.replace('.xlsx', '');
206
+ const filePath = path.join(this.filePath, file);
207
+
208
+ try {
209
+ const workbook = new ExcelJS.Workbook();
210
+ await workbook.xlsx.readFile(filePath);
211
+ this.workbooks.set(objectName, workbook);
212
+
213
+ // Load data from first worksheet
214
+ const worksheet = workbook.worksheets[0];
215
+ if (worksheet) {
216
+ this.loadDataFromSingleWorksheet(worksheet, objectName);
217
+ }
218
+ } catch (error) {
219
+ console.warn(`[ExcelDriver] Warning: Failed to load file ${file}:`, (error as Error).message);
220
+ }
221
+ }
222
+ }
223
+ }
224
+
225
+ /**
226
+ * Load data from workbook into memory.
227
+ *
228
+ * Expected Excel format:
229
+ * - First row contains column headers (field names)
230
+ * - Subsequent rows contain data records
231
+ * - Each worksheet represents one object type
232
+ */
233
+ private loadDataFromWorkbook(): void {
234
+ this.workbook.eachSheet((worksheet) => {
235
+ this.loadDataFromSingleWorksheet(worksheet, worksheet.name);
236
+ });
237
+ }
238
+
239
+ /**
240
+ * Load data from a single worksheet into memory.
241
+ */
242
+ private loadDataFromSingleWorksheet(worksheet: ExcelJS.Worksheet, objectName: string): void {
243
+ const records: any[] = [];
244
+
245
+ // Get headers from first row
246
+ const headerRow = worksheet.getRow(1);
247
+ const headers: string[] = [];
248
+ headerRow.eachCell((cell: any, colNumber: number) => {
249
+ const headerValue = cell.value;
250
+ if (headerValue) {
251
+ headers[colNumber - 1] = String(headerValue);
252
+ }
253
+ });
254
+
255
+ // Warn if worksheet has no headers (might be corrupted or wrong format)
256
+ if (headers.length === 0 && worksheet.rowCount > 0) {
257
+ console.warn(`[ExcelDriver] Warning: Worksheet "${objectName}" has no headers in first row. Skipping.`);
258
+ return;
259
+ }
260
+
261
+ // Skip first row (headers) and read data rows
262
+ let rowsProcessed = 0;
263
+ let rowsSkipped = 0;
264
+
265
+ worksheet.eachRow((row: any, rowNumber: number) => {
266
+ if (rowNumber === 1) return; // Skip header row
267
+
268
+ const record: any = {};
269
+ let hasData = false;
270
+
271
+ row.eachCell((cell: any, colNumber: number) => {
272
+ const header = headers[colNumber - 1];
273
+ if (header) {
274
+ record[header] = cell.value;
275
+ hasData = true;
276
+ }
277
+ });
278
+
279
+ // Skip completely empty rows
280
+ if (!hasData) {
281
+ rowsSkipped++;
282
+ return;
283
+ }
284
+
285
+ // Ensure ID exists
286
+ if (!record.id) {
287
+ record.id = this.generateId(objectName);
288
+ }
289
+
290
+ records.push(record);
291
+ rowsProcessed++;
292
+ });
293
+
294
+ // Log summary for debugging
295
+ if (rowsSkipped > 0) {
296
+ console.warn(`[ExcelDriver] Worksheet "${objectName}": Processed ${rowsProcessed} rows, skipped ${rowsSkipped} empty rows`);
297
+ }
298
+
299
+ this.data.set(objectName, records);
300
+ this.updateIdCounter(objectName, records);
301
+ }
302
+
303
+ /**
304
+ * Update ID counter based on existing records.
305
+ *
306
+ * Attempts to extract counter from auto-generated IDs (format: objectName-timestamp-counter).
307
+ * If IDs don't follow this format, counter starts from existing record count.
308
+ */
309
+ private updateIdCounter(objectName: string, records: any[]): void {
310
+ let maxCounter = 0;
311
+ for (const record of records) {
312
+ if (record.id) {
313
+ // Try to extract counter from generated IDs (format: objectName-timestamp-counter)
314
+ const idStr = String(record.id);
315
+ const parts = idStr.split('-');
316
+
317
+ // Only parse if it matches the expected auto-generated format
318
+ if (parts.length === 3 && parts[0] === objectName && !isNaN(Number(parts[2]))) {
319
+ const counter = Number(parts[2]);
320
+ if (counter > maxCounter) {
321
+ maxCounter = counter;
322
+ }
323
+ }
324
+ }
325
+ }
326
+
327
+ // If no auto-generated IDs found, start from record count to avoid collisions
328
+ if (maxCounter === 0 && records.length > 0) {
329
+ maxCounter = records.length;
330
+ }
331
+
332
+ this.idCounters.set(objectName, maxCounter);
333
+ }
334
+
335
+ /**
336
+ * Save workbook to file.
337
+ */
338
+ /**
339
+ * Save workbook to file.
340
+ * Handles both single-file and file-per-object modes.
341
+ */
342
+ private async saveWorkbook(objectName?: string): Promise<void> {
343
+ if (this.fileStorageMode === 'single-file') {
344
+ await this.saveSingleFileWorkbook();
345
+ } else if (objectName) {
346
+ await this.saveFilePerObjectWorkbook(objectName);
347
+ }
348
+ }
349
+
350
+ /**
351
+ * Save workbook in single-file mode.
352
+ */
353
+ private async saveSingleFileWorkbook(): Promise<void> {
354
+ try {
355
+ // Ensure directory exists
356
+ const dir = path.dirname(this.filePath);
357
+ if (!fs.existsSync(dir)) {
358
+ fs.mkdirSync(dir, { recursive: true });
359
+ }
360
+
361
+ // Write workbook to file
362
+ await this.workbook.xlsx.writeFile(this.filePath);
363
+ } catch (error) {
364
+ throw new ObjectQLError({
365
+ code: 'FILE_WRITE_ERROR',
366
+ message: `Failed to write Excel file: ${this.filePath}`,
367
+ details: { error: (error as Error).message }
368
+ });
369
+ }
370
+ }
371
+
372
+ /**
373
+ * Save workbook in file-per-object mode.
374
+ */
375
+ private async saveFilePerObjectWorkbook(objectName: string): Promise<void> {
376
+ const workbook = this.workbooks.get(objectName);
377
+ if (!workbook) {
378
+ throw new ObjectQLError({
379
+ code: 'WORKBOOK_NOT_FOUND',
380
+ message: `Workbook not found for object: ${objectName}`,
381
+ details: { objectName }
382
+ });
383
+ }
384
+
385
+ try {
386
+ const filePath = path.join(this.filePath, `${objectName}.xlsx`);
387
+ await workbook.xlsx.writeFile(filePath);
388
+ } catch (error) {
389
+ throw new ObjectQLError({
390
+ code: 'FILE_WRITE_ERROR',
391
+ message: `Failed to write Excel file for object: ${objectName}`,
392
+ details: { objectName, error: (error as Error).message }
393
+ });
394
+ }
395
+ }
396
+
397
+ /**
398
+ * Sync in-memory data to workbook.
399
+ *
400
+ * Creates or updates a worksheet for the given object type.
401
+ */
402
+ private async syncToWorkbook(objectName: string): Promise<void> {
403
+ if (this.fileStorageMode === 'single-file') {
404
+ await this.syncToSingleFileWorkbook(objectName);
405
+ } else {
406
+ await this.syncToFilePerObjectWorkbook(objectName);
407
+ }
408
+ }
409
+
410
+ /**
411
+ * Sync data to worksheet in single-file mode.
412
+ */
413
+ private async syncToSingleFileWorkbook(objectName: string): Promise<void> {
414
+ const records = this.data.get(objectName) || [];
415
+
416
+ // Remove existing worksheet if it exists
417
+ const existingSheet = this.workbook.getWorksheet(objectName);
418
+ if (existingSheet) {
419
+ this.workbook.removeWorksheet(existingSheet.id);
420
+ }
421
+
422
+ // Create new worksheet
423
+ const worksheet = this.workbook.addWorksheet(objectName);
424
+
425
+ if (records.length > 0) {
426
+ // Get all unique keys from records to create headers
427
+ const headers = new Set<string>();
428
+ records.forEach(record => {
429
+ Object.keys(record).forEach(key => headers.add(key));
430
+ });
431
+ const headerArray = Array.from(headers);
432
+
433
+ // Add header row
434
+ worksheet.addRow(headerArray);
435
+
436
+ // Add data rows
437
+ records.forEach(record => {
438
+ const row = headerArray.map(header => record[header]);
439
+ worksheet.addRow(row);
440
+ });
441
+
442
+ // Auto-fit columns
443
+ worksheet.columns.forEach((column: any) => {
444
+ if (column.header) {
445
+ column.width = Math.max(10, String(column.header).length + 2);
446
+ }
447
+ });
448
+ }
449
+
450
+ // Auto-save if enabled
451
+ if (this.config.autoSave) {
452
+ await this.saveWorkbook();
453
+ }
454
+ }
455
+
456
+ /**
457
+ * Sync data to separate file in file-per-object mode.
458
+ */
459
+ private async syncToFilePerObjectWorkbook(objectName: string): Promise<void> {
460
+ const records = this.data.get(objectName) || [];
461
+
462
+ // Get or create workbook for this object
463
+ let workbook = this.workbooks.get(objectName);
464
+ if (!workbook) {
465
+ workbook = new ExcelJS.Workbook();
466
+ this.workbooks.set(objectName, workbook);
467
+ }
468
+
469
+ // Remove all existing worksheets
470
+ workbook.worksheets.forEach(ws => workbook!.removeWorksheet(ws.id));
471
+
472
+ // Create new worksheet
473
+ const worksheet = workbook.addWorksheet(objectName);
474
+
475
+ if (records.length > 0) {
476
+ // Get all unique keys from records to create headers
477
+ const headers = new Set<string>();
478
+ records.forEach(record => {
479
+ Object.keys(record).forEach(key => headers.add(key));
480
+ });
481
+ const headerArray = Array.from(headers);
482
+
483
+ // Add header row
484
+ worksheet.addRow(headerArray);
485
+
486
+ // Add data rows
487
+ records.forEach(record => {
488
+ const row = headerArray.map(header => record[header]);
489
+ worksheet.addRow(row);
490
+ });
491
+
492
+ // Auto-fit columns
493
+ worksheet.columns.forEach((column: any) => {
494
+ if (column.header) {
495
+ column.width = Math.max(10, String(column.header).length + 2);
496
+ }
497
+ });
498
+ }
499
+
500
+ // Auto-save if enabled
501
+ if (this.config.autoSave) {
502
+ await this.saveWorkbook(objectName);
503
+ }
504
+ }
505
+
506
+ /**
507
+ * Find multiple records matching the query criteria.
508
+ */
509
+ async find(objectName: string, query: any = {}, options?: any): Promise<any[]> {
510
+ let results = this.data.get(objectName) || [];
511
+
512
+ // Return empty array if no data
513
+ if (results.length === 0) {
514
+ return [];
515
+ }
516
+
517
+ // Deep copy to avoid mutations
518
+ results = results.map(r => ({ ...r }));
519
+
520
+ // Apply filters
521
+ if (query.filters) {
522
+ results = this.applyFilters(results, query.filters);
523
+ }
524
+
525
+ // Apply sorting
526
+ if (query.sort && Array.isArray(query.sort)) {
527
+ results = this.applySort(results, query.sort);
528
+ }
529
+
530
+ // Apply pagination
531
+ if (query.skip) {
532
+ results = results.slice(query.skip);
533
+ }
534
+ if (query.limit) {
535
+ results = results.slice(0, query.limit);
536
+ }
537
+
538
+ // Apply field projection
539
+ if (query.fields && Array.isArray(query.fields)) {
540
+ results = results.map(doc => this.projectFields(doc, query.fields));
541
+ }
542
+
543
+ return results;
544
+ }
545
+
546
+ /**
547
+ * Find a single record by ID or query.
548
+ */
549
+ async findOne(objectName: string, id: string | number, query?: any, options?: any): Promise<any> {
550
+ const records = this.data.get(objectName) || [];
551
+
552
+ // If ID is provided, fetch directly
553
+ if (id) {
554
+ const record = records.find(r => r.id === String(id));
555
+ return record ? { ...record } : null;
556
+ }
557
+
558
+ // If query is provided, use find and return first result
559
+ if (query) {
560
+ const results = await this.find(objectName, { ...query, limit: 1 }, options);
561
+ return results[0] || null;
562
+ }
563
+
564
+ return null;
565
+ }
566
+
567
+ /**
568
+ * Create a new record.
569
+ */
570
+ async create(objectName: string, data: any, options?: any): Promise<any> {
571
+ // Get or create object data array
572
+ if (!this.data.has(objectName)) {
573
+ this.data.set(objectName, []);
574
+ }
575
+
576
+ const records = this.data.get(objectName)!;
577
+
578
+ // Generate ID if not provided
579
+ const id = data.id || this.generateId(objectName);
580
+
581
+ // Check if record already exists
582
+ if (records.some(r => r.id === id)) {
583
+ throw new ObjectQLError({
584
+ code: 'DUPLICATE_RECORD',
585
+ message: `Record with id '${id}' already exists in '${objectName}'`,
586
+ details: { objectName, id }
587
+ });
588
+ }
589
+
590
+ const now = new Date().toISOString();
591
+ const doc = {
592
+ id,
593
+ ...data,
594
+ created_at: data.created_at || now,
595
+ updated_at: data.updated_at || now
596
+ };
597
+
598
+ records.push(doc);
599
+ await this.syncToWorkbook(objectName);
600
+
601
+ return { ...doc };
602
+ }
603
+
604
+ /**
605
+ * Update an existing record.
606
+ */
607
+ async update(objectName: string, id: string | number, data: any, options?: any): Promise<any> {
608
+ const records = this.data.get(objectName) || [];
609
+ const index = records.findIndex(r => r.id === String(id));
610
+
611
+ if (index === -1) {
612
+ if (this.config.strictMode) {
613
+ throw new ObjectQLError({
614
+ code: 'RECORD_NOT_FOUND',
615
+ message: `Record with id '${id}' not found in '${objectName}'`,
616
+ details: { objectName, id }
617
+ });
618
+ }
619
+ return null;
620
+ }
621
+
622
+ const existing = records[index];
623
+ const doc = {
624
+ ...existing,
625
+ ...data,
626
+ id: existing.id, // Preserve ID
627
+ created_at: existing.created_at, // Preserve created_at
628
+ updated_at: new Date().toISOString()
629
+ };
630
+
631
+ records[index] = doc;
632
+ await this.syncToWorkbook(objectName);
633
+
634
+ return { ...doc };
635
+ }
636
+
637
+ /**
638
+ * Delete a record.
639
+ */
640
+ async delete(objectName: string, id: string | number, options?: any): Promise<any> {
641
+ const records = this.data.get(objectName) || [];
642
+ const index = records.findIndex(r => r.id === String(id));
643
+
644
+ if (index === -1) {
645
+ if (this.config.strictMode) {
646
+ throw new ObjectQLError({
647
+ code: 'RECORD_NOT_FOUND',
648
+ message: `Record with id '${id}' not found in '${objectName}'`,
649
+ details: { objectName, id }
650
+ });
651
+ }
652
+ return false;
653
+ }
654
+
655
+ records.splice(index, 1);
656
+ await this.syncToWorkbook(objectName);
657
+
658
+ return true;
659
+ }
660
+
661
+ /**
662
+ * Count records matching filters.
663
+ */
664
+ async count(objectName: string, filters: any, options?: any): Promise<number> {
665
+ const records = this.data.get(objectName) || [];
666
+
667
+ // Extract actual filters from query object if needed
668
+ let actualFilters = filters;
669
+ if (filters && !Array.isArray(filters) && filters.filters) {
670
+ actualFilters = filters.filters;
671
+ }
672
+
673
+ // If no filters or empty object/array, return total count
674
+ if (!actualFilters ||
675
+ (Array.isArray(actualFilters) && actualFilters.length === 0) ||
676
+ (typeof actualFilters === 'object' && !Array.isArray(actualFilters) && Object.keys(actualFilters).length === 0)) {
677
+ return records.length;
678
+ }
679
+
680
+ // Count only records matching filters
681
+ return records.filter(record => this.matchesFilters(record, actualFilters)).length;
682
+ }
683
+
684
+ /**
685
+ * Get distinct values for a field.
686
+ */
687
+ async distinct(objectName: string, field: string, filters?: any, options?: any): Promise<any[]> {
688
+ const records = this.data.get(objectName) || [];
689
+ const values = new Set<any>();
690
+
691
+ for (const record of records) {
692
+ if (!filters || this.matchesFilters(record, filters)) {
693
+ const value = record[field];
694
+ if (value !== undefined && value !== null) {
695
+ values.add(value);
696
+ }
697
+ }
698
+ }
699
+
700
+ return Array.from(values);
701
+ }
702
+
703
+ /**
704
+ * Create multiple records at once.
705
+ */
706
+ async createMany(objectName: string, data: any[], options?: any): Promise<any> {
707
+ const results = [];
708
+ for (const item of data) {
709
+ const result = await this.create(objectName, item, options);
710
+ results.push(result);
711
+ }
712
+ return results;
713
+ }
714
+
715
+ /**
716
+ * Update multiple records matching filters.
717
+ */
718
+ async updateMany(objectName: string, filters: any, data: any, options?: any): Promise<any> {
719
+ const records = this.data.get(objectName) || [];
720
+ let count = 0;
721
+
722
+ for (let i = 0; i < records.length; i++) {
723
+ if (this.matchesFilters(records[i], filters)) {
724
+ records[i] = {
725
+ ...records[i],
726
+ ...data,
727
+ id: records[i].id, // Preserve ID
728
+ created_at: records[i].created_at, // Preserve created_at
729
+ updated_at: new Date().toISOString()
730
+ };
731
+ count++;
732
+ }
733
+ }
734
+
735
+ if (count > 0) {
736
+ await this.syncToWorkbook(objectName);
737
+ }
738
+
739
+ return { modifiedCount: count };
740
+ }
741
+
742
+ /**
743
+ * Delete multiple records matching filters.
744
+ */
745
+ async deleteMany(objectName: string, filters: any, options?: any): Promise<any> {
746
+ const records = this.data.get(objectName) || [];
747
+ const initialLength = records.length;
748
+
749
+ const filtered = records.filter(record => !this.matchesFilters(record, filters));
750
+ this.data.set(objectName, filtered);
751
+
752
+ const deletedCount = initialLength - filtered.length;
753
+
754
+ if (deletedCount > 0) {
755
+ await this.syncToWorkbook(objectName);
756
+ }
757
+
758
+ return { deletedCount };
759
+ }
760
+
761
+ /**
762
+ * Manually save the workbook to file.
763
+ */
764
+ /**
765
+ * Manually save the workbook to file.
766
+ */
767
+ async save(): Promise<void> {
768
+ if (this.fileStorageMode === 'single-file') {
769
+ await this.saveWorkbook();
770
+ } else {
771
+ // Save all object files in file-per-object mode
772
+ for (const objectName of this.data.keys()) {
773
+ await this.saveWorkbook(objectName);
774
+ }
775
+ }
776
+ }
777
+
778
+ /**
779
+ * Disconnect (flush any pending writes).
780
+ */
781
+ async disconnect(): Promise<void> {
782
+ if (this.config.autoSave) {
783
+ await this.save();
784
+ }
785
+ }
786
+
787
+ // ========== Helper Methods ==========
788
+
789
+ /**
790
+ * Apply filters to an array of records (in-memory filtering).
791
+ */
792
+ private applyFilters(records: any[], filters: any[]): any[] {
793
+ if (!filters || filters.length === 0) {
794
+ return records;
795
+ }
796
+
797
+ return records.filter(record => this.matchesFilters(record, filters));
798
+ }
799
+
800
+ /**
801
+ * Check if a single record matches the filter conditions.
802
+ */
803
+ private matchesFilters(record: any, filters: any[]): boolean {
804
+ if (!filters || filters.length === 0) {
805
+ return true;
806
+ }
807
+
808
+ let conditions: boolean[] = [];
809
+ let operators: string[] = [];
810
+
811
+ for (const item of filters) {
812
+ if (typeof item === 'string') {
813
+ // Logical operator (and/or)
814
+ operators.push(item.toLowerCase());
815
+ } else if (Array.isArray(item)) {
816
+ const [field, operator, value] = item;
817
+
818
+ // Handle nested filter groups
819
+ if (typeof field !== 'string') {
820
+ // Nested group - recursively evaluate
821
+ conditions.push(this.matchesFilters(record, item));
822
+ } else {
823
+ // Single condition
824
+ const matches = this.evaluateCondition(record[field], operator, value);
825
+ conditions.push(matches);
826
+ }
827
+ }
828
+ }
829
+
830
+ // Combine conditions with operators
831
+ if (conditions.length === 0) {
832
+ return true;
833
+ }
834
+
835
+ let result = conditions[0];
836
+ for (let i = 0; i < operators.length; i++) {
837
+ const op = operators[i];
838
+ const nextCondition = conditions[i + 1];
839
+
840
+ if (op === 'or') {
841
+ result = result || nextCondition;
842
+ } else { // 'and' or default
843
+ result = result && nextCondition;
844
+ }
845
+ }
846
+
847
+ return result;
848
+ }
849
+
850
+ /**
851
+ * Evaluate a single filter condition.
852
+ */
853
+ private evaluateCondition(fieldValue: any, operator: string, compareValue: any): boolean {
854
+ switch (operator) {
855
+ case '=':
856
+ case '==':
857
+ return fieldValue === compareValue;
858
+ case '!=':
859
+ case '<>':
860
+ return fieldValue !== compareValue;
861
+ case '>':
862
+ return fieldValue > compareValue;
863
+ case '>=':
864
+ return fieldValue >= compareValue;
865
+ case '<':
866
+ return fieldValue < compareValue;
867
+ case '<=':
868
+ return fieldValue <= compareValue;
869
+ case 'in':
870
+ return Array.isArray(compareValue) && compareValue.includes(fieldValue);
871
+ case 'nin':
872
+ case 'not in':
873
+ return Array.isArray(compareValue) && !compareValue.includes(fieldValue);
874
+ case 'contains':
875
+ case 'like':
876
+ return String(fieldValue).toLowerCase().includes(String(compareValue).toLowerCase());
877
+ case 'startswith':
878
+ case 'starts_with':
879
+ return String(fieldValue).toLowerCase().startsWith(String(compareValue).toLowerCase());
880
+ case 'endswith':
881
+ case 'ends_with':
882
+ return String(fieldValue).toLowerCase().endsWith(String(compareValue).toLowerCase());
883
+ case 'between':
884
+ return Array.isArray(compareValue) &&
885
+ fieldValue >= compareValue[0] &&
886
+ fieldValue <= compareValue[1];
887
+ default:
888
+ throw new ObjectQLError({
889
+ code: 'UNSUPPORTED_OPERATOR',
890
+ message: `[ExcelDriver] Unsupported operator: ${operator}`,
891
+ });
892
+ }
893
+ }
894
+
895
+ /**
896
+ * Apply sorting to an array of records.
897
+ */
898
+ private applySort(records: any[], sort: any[]): any[] {
899
+ const sorted = [...records];
900
+
901
+ // Apply sorts in reverse order for correct precedence
902
+ for (let i = sort.length - 1; i >= 0; i--) {
903
+ const sortItem = sort[i];
904
+
905
+ let field: string;
906
+ let direction: string;
907
+
908
+ if (Array.isArray(sortItem)) {
909
+ [field, direction] = sortItem;
910
+ } else if (typeof sortItem === 'object') {
911
+ field = sortItem.field;
912
+ direction = sortItem.order || sortItem.direction || sortItem.dir || 'asc';
913
+ } else {
914
+ continue;
915
+ }
916
+
917
+ sorted.sort((a, b) => {
918
+ const aVal = a[field];
919
+ const bVal = b[field];
920
+
921
+ // Handle null/undefined
922
+ if (aVal == null && bVal == null) return 0;
923
+ if (aVal == null) return 1;
924
+ if (bVal == null) return -1;
925
+
926
+ // Compare values
927
+ if (aVal < bVal) return direction === 'asc' ? -1 : 1;
928
+ if (aVal > bVal) return direction === 'asc' ? 1 : -1;
929
+ return 0;
930
+ });
931
+ }
932
+
933
+ return sorted;
934
+ }
935
+
936
+ /**
937
+ * Project specific fields from a document.
938
+ */
939
+ private projectFields(doc: any, fields: string[]): any {
940
+ const result: any = {};
941
+ for (const field of fields) {
942
+ if (doc[field] !== undefined) {
943
+ result[field] = doc[field];
944
+ }
945
+ }
946
+ return result;
947
+ }
948
+
949
+ /**
950
+ * Generate a unique ID for a record.
951
+ */
952
+ private generateId(objectName: string): string {
953
+ const counter = (this.idCounters.get(objectName) || 0) + 1;
954
+ this.idCounters.set(objectName, counter);
955
+
956
+ // Use timestamp + counter for better uniqueness
957
+ const timestamp = Date.now();
958
+ return `${objectName}-${timestamp}-${counter}`;
959
+ }
960
+ }