@objectql/driver-excel 4.0.2 → 4.0.3

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 CHANGED
@@ -1,7 +1,5 @@
1
- import { Data, Driver as DriverSpec } from '@objectstack/spec';
2
- type QueryAST = Data.QueryAST;
3
- type SortNode = Data.SortNode;
4
- type DriverInterface = DriverSpec.DriverInterface;
1
+ import { Data } from '@objectstack/spec';
2
+ type DriverInterface = Data.DriverInterface;
5
3
  /**
6
4
  * ObjectQL
7
5
  * Copyright (c) 2026-present ObjectStack Inc.
@@ -13,54 +11,37 @@ type DriverInterface = DriverSpec.DriverInterface;
13
11
  /**
14
12
  * Excel Driver for ObjectQL (Production-Ready)
15
13
  *
16
- * A driver for ObjectQL that reads and writes data from Excel (.xlsx) files.
17
- * Each worksheet in the Excel file represents an object type, with the first row
18
- * containing column headers (field names) and subsequent rows containing data.
14
+ * A persistent Excel-based driver for ObjectQL that stores data in Excel (.xlsx) files.
15
+ * Extends MemoryDriver with Excel file persistence using ExcelJS.
19
16
  *
20
- * Features:
21
- * - Read data from Excel files
22
- * - Write data back to Excel files
23
- * - Multiple sheets support (one sheet per object type)
24
- * - Full CRUD operations
25
- * - Query support (filters, sorting, pagination)
26
- * - Automatic data type handling
27
- * - Secure: Uses ExcelJS (no known vulnerabilities)
17
+ * Implements both the legacy Driver interface from @objectql/types and
18
+ * the standard DriverInterface from @objectstack/spec for compatibility
19
+ * with the new kernel-based plugin system.
20
+ *
21
+ * ✅ Production-ready features:
22
+ * - Persistent storage with Excel files
23
+ * - Support for both single-file and file-per-object modes
24
+ * - One worksheet per table/object (e.g., users sheet, projects sheet)
25
+ * - Atomic write operations
26
+ * - Full query support (filters, sorting, pagination) inherited from MemoryDriver
27
+ * - Human-readable Excel format
28
28
  *
29
29
  * Use Cases:
30
30
  * - Import/export data from Excel spreadsheets
31
31
  * - Use Excel as a simple database for prototyping
32
32
  * - Data migration from Excel to other databases
33
33
  * - Generate reports in Excel format
34
+ * - Small to medium datasets (< 10k records per object)
34
35
  */
35
36
 
36
- import { Driver, ObjectQLError } from '@objectql/types';
37
- import * as ExcelJS from 'exceljs';
38
37
  import * as fs from 'fs';
39
38
  import * as path from 'path';
39
+ import * as ExcelJS from 'exceljs';
40
+ import { ObjectQLError } from '@objectql/types';
41
+ import { MemoryDriver, MemoryDriverConfig, Command, CommandResult } from '@objectql/driver-memory';
40
42
 
41
- /**
42
- * Command interface for executeCommand method
43
- */
44
- export interface Command {
45
- type: 'create' | 'update' | 'delete' | 'bulkCreate' | 'bulkUpdate' | 'bulkDelete';
46
- object: string;
47
- data?: any;
48
- id?: string | number;
49
- ids?: Array<string | number>;
50
- records?: any[];
51
- updates?: Array<{id: string | number, data: any}>;
52
- options?: any;
53
- }
54
-
55
- /**
56
- * Command result interface
57
- */
58
- export interface CommandResult {
59
- success: boolean;
60
- data?: any;
61
- affected: number;
62
- error?: string;
63
- }
43
+ // Re-export Command and CommandResult for compatibility
44
+ export type { Command, CommandResult };
64
45
 
65
46
  /**
66
47
  * File storage mode for the Excel driver.
@@ -70,7 +51,7 @@ export type FileStorageMode = 'single-file' | 'file-per-object';
70
51
  /**
71
52
  * Configuration options for the Excel driver.
72
53
  */
73
- export interface ExcelDriverConfig {
54
+ export interface ExcelDriverConfig extends MemoryDriverConfig {
74
55
  /**
75
56
  * Path to the Excel file or directory.
76
57
  * - In 'single-file' mode: Path to a single .xlsx file
@@ -87,17 +68,21 @@ export interface ExcelDriverConfig {
87
68
 
88
69
  /** Optional: Auto-save changes to file (default: true) */
89
70
  autoSave?: boolean;
71
+
90
72
  /** Optional: Create file if it doesn't exist (default: true) */
91
73
  createIfMissing?: boolean;
92
- /** Optional: Enable strict mode (throw on missing objects) */
93
- strictMode?: boolean;
94
74
  }
95
75
 
96
76
  /**
97
77
  * Excel Driver Implementation
98
78
  *
99
- * Stores ObjectQL documents in Excel worksheets. Each object type is stored
100
- * in a separate worksheet, with the first row containing column headers.
79
+ * Extends MemoryDriver with Excel file persistence.
80
+ * All query and aggregation logic is inherited from MemoryDriver.
81
+ * Only the persistence layer (load/save) is overridden.
82
+ *
83
+ * Stores ObjectQL documents in Excel worksheets with format:
84
+ * - Single-file mode: All objects as worksheets in one .xlsx file
85
+ * - File-per-object mode: Each object in a separate .xlsx file
101
86
  *
102
87
  * Uses ExcelJS library for secure Excel file operations.
103
88
  *
@@ -105,54 +90,38 @@ export interface ExcelDriverConfig {
105
90
  * the standard DriverInterface from @objectstack/spec for compatibility
106
91
  * with the new kernel-based plugin system.
107
92
  */
108
- export class ExcelDriver implements Driver {
109
- // Driver metadata (ObjectStack-compatible)
110
- public readonly name = 'ExcelDriver';
111
- public readonly version = '4.0.0';
112
- public readonly supports = {
113
- transactions: false,
114
- joins: false,
115
- fullTextSearch: false,
116
- jsonFields: true,
117
- arrayFields: true,
118
- queryFilters: true,
119
- queryAggregations: false,
120
- querySorting: true,
121
- queryPagination: true,
122
- queryWindowFunctions: false,
123
- querySubqueries: false
124
- };
125
-
126
- private config: ExcelDriverConfig;
127
- private workbook!: ExcelJS.Workbook;
128
- private workbooks: Map<string, ExcelJS.Workbook>; // For file-per-object mode
129
- private data: Map<string, any[]>;
130
- private idCounters: Map<string, number>;
93
+ export class ExcelDriver extends MemoryDriver {
131
94
  private filePath: string;
132
95
  private fileStorageMode: FileStorageMode;
96
+ private autoSave: boolean;
97
+ private createIfMissing: boolean;
98
+ private workbook!: ExcelJS.Workbook; // For single-file mode
99
+ private workbooks: Map<string, ExcelJS.Workbook>; // For file-per-object mode
100
+ private fileLocks: Map<string, boolean>; // Simple in-memory file locks
133
101
 
134
102
  constructor(config: ExcelDriverConfig) {
135
- this.config = {
136
- autoSave: true,
137
- createIfMissing: true,
138
- strictMode: false,
139
- fileStorageMode: 'single-file',
140
- ...config
141
- };
103
+ // Initialize parent with inherited config properties
104
+ super({
105
+ strictMode: config.strictMode,
106
+ initialData: config.initialData,
107
+ indexes: config.indexes
108
+ });
109
+
110
+ // Override driver name and version
111
+ (this as any).name = 'ExcelDriver';
112
+ (this as any).version = '4.0.0';
142
113
 
143
114
  this.filePath = path.resolve(config.filePath);
144
- this.fileStorageMode = this.config.fileStorageMode!;
145
- this.data = new Map<string, any[]>();
146
- this.idCounters = new Map<string, number>();
115
+ this.fileStorageMode = config.fileStorageMode || 'single-file';
116
+ this.autoSave = config.autoSave !== false;
117
+ this.createIfMissing = config.createIfMissing !== false;
147
118
  this.workbooks = new Map<string, ExcelJS.Workbook>();
148
-
119
+ this.fileLocks = new Map<string, boolean>();
120
+
149
121
  // Initialize workbook for single-file mode
150
122
  if (this.fileStorageMode === 'single-file') {
151
123
  this.workbook = new ExcelJS.Workbook();
152
124
  }
153
-
154
- // Note: Actual file loading happens in init()
155
- // Call init() after construction or use the async create() factory method
156
125
  }
157
126
 
158
127
  /**
@@ -165,56 +134,41 @@ export class ExcelDriver implements Driver {
165
134
 
166
135
  /**
167
136
  * Connect to the database (for DriverInterface compatibility)
168
- * This calls init() to load the workbook.
169
137
  */
170
138
  async connect(): Promise<void> {
171
139
  await this.init();
172
140
  }
173
141
 
142
+ /**
143
+ * Factory method to create and initialize the driver.
144
+ */
145
+ static async create(config: ExcelDriverConfig): Promise<ExcelDriver> {
146
+ const driver = new ExcelDriver(config);
147
+ await driver.init();
148
+ return driver;
149
+ }
150
+
174
151
  /**
175
152
  * Check database connection health
176
153
  */
177
154
  async checkHealth(): Promise<boolean> {
178
155
  try {
179
156
  if (this.fileStorageMode === 'single-file') {
180
- // Check if file exists or can be created
181
- if (!fs.existsSync(this.filePath)) {
182
- if (!this.config.createIfMissing) {
183
- return false;
184
- }
185
- // Check if directory is writable
186
- const dir = path.dirname(this.filePath);
187
- if (!fs.existsSync(dir)) {
188
- return false;
189
- }
157
+ if (!fs.existsSync(this.filePath) && !this.createIfMissing) {
158
+ return false;
190
159
  }
191
- return true;
160
+ const dir = path.dirname(this.filePath);
161
+ return fs.existsSync(dir) || this.createIfMissing;
192
162
  } else {
193
- // Check if directory exists or can be created
194
- if (!fs.existsSync(this.filePath)) {
195
- if (!this.config.createIfMissing) {
196
- return false;
197
- }
198
- }
199
- return true;
163
+ return fs.existsSync(this.filePath) || this.createIfMissing;
200
164
  }
201
- } catch (error) {
165
+ } catch {
202
166
  return false;
203
167
  }
204
168
  }
205
169
 
206
- /**
207
- * Factory method to create and initialize the driver.
208
- */
209
- static async create(config: ExcelDriverConfig): Promise<ExcelDriver> {
210
- const driver = new ExcelDriver(config);
211
- await driver.init();
212
- return driver;
213
- }
214
-
215
170
  /**
216
171
  * Load workbook from file or create a new one.
217
- * Handles both single-file and file-per-object modes.
218
172
  */
219
173
  private async loadWorkbook(): Promise<void> {
220
174
  if (this.fileStorageMode === 'single-file') {
@@ -225,39 +179,37 @@ export class ExcelDriver implements Driver {
225
179
  }
226
180
 
227
181
  /**
228
- * Load workbook in single-file mode (all objects in one Excel file).
182
+ * Load workbook in single-file mode.
229
183
  */
230
184
  private async loadSingleFileWorkbook(): Promise<void> {
231
185
  this.workbook = new ExcelJS.Workbook();
232
186
 
233
187
  if (fs.existsSync(this.filePath)) {
188
+ await this.acquireLock(this.filePath);
234
189
  try {
235
190
  await this.workbook.xlsx.readFile(this.filePath);
236
191
  this.loadDataFromWorkbook();
237
192
  } catch (error) {
193
+ this.releaseLock(this.filePath);
238
194
  const errorMessage = (error as Error).message;
239
195
 
240
- // Provide helpful error messages for common issues
241
196
  let detailedMessage = `Failed to read Excel file: ${this.filePath}`;
242
197
  if (errorMessage.includes('corrupted') || errorMessage.includes('invalid')) {
243
198
  detailedMessage += ' - File may be corrupted or not a valid .xlsx file';
244
199
  } else if (errorMessage.includes('permission') || errorMessage.includes('EACCES')) {
245
- detailedMessage += ' - Permission denied. Check file permissions.';
200
+ detailedMessage += ' - Permission denied';
246
201
  } else if (errorMessage.includes('EBUSY')) {
247
- detailedMessage += ' - File is locked by another process. Close it and try again.';
202
+ detailedMessage += ' - File is locked by another process';
248
203
  }
249
204
 
250
205
  throw new ObjectQLError({
251
206
  code: 'FILE_READ_ERROR',
252
207
  message: detailedMessage,
253
- details: {
254
- filePath: this.filePath,
255
- error: errorMessage
256
- }
208
+ details: { filePath: this.filePath, error: errorMessage }
257
209
  });
258
210
  }
259
- } else if (this.config.createIfMissing) {
260
- // Create new empty workbook
211
+ this.releaseLock(this.filePath);
212
+ } else if (this.createIfMissing) {
261
213
  await this.saveWorkbook();
262
214
  } else {
263
215
  throw new ObjectQLError({
@@ -269,12 +221,11 @@ export class ExcelDriver implements Driver {
269
221
  }
270
222
 
271
223
  /**
272
- * Load workbooks in file-per-object mode (each object in separate Excel file).
224
+ * Load workbooks in file-per-object mode.
273
225
  */
274
226
  private async loadFilePerObjectWorkbooks(): Promise<void> {
275
- // Ensure directory exists
276
227
  if (!fs.existsSync(this.filePath)) {
277
- if (this.config.createIfMissing) {
228
+ if (this.createIfMissing) {
278
229
  fs.mkdirSync(this.filePath, { recursive: true });
279
230
  } else {
280
231
  throw new ObjectQLError({
@@ -285,7 +236,6 @@ export class ExcelDriver implements Driver {
285
236
  }
286
237
  }
287
238
 
288
- // Check if it's actually a directory
289
239
  const stats = fs.statSync(this.filePath);
290
240
  if (!stats.isDirectory()) {
291
241
  throw new ObjectQLError({
@@ -295,147 +245,89 @@ export class ExcelDriver implements Driver {
295
245
  });
296
246
  }
297
247
 
298
- // Load all existing .xlsx files in the directory
299
248
  const files = fs.readdirSync(this.filePath);
300
249
  for (const file of files) {
301
- if (file.endsWith('.xlsx') && !file.startsWith('~$')) {
250
+ if (file.endsWith('.xlsx')) {
302
251
  const objectName = file.replace('.xlsx', '');
303
252
  const filePath = path.join(this.filePath, file);
304
253
 
254
+ await this.acquireLock(filePath);
305
255
  try {
306
256
  const workbook = new ExcelJS.Workbook();
307
257
  await workbook.xlsx.readFile(filePath);
308
258
  this.workbooks.set(objectName, workbook);
309
-
310
- // Load data from first worksheet
311
- const worksheet = workbook.worksheets[0];
312
- if (worksheet) {
313
- this.loadDataFromSingleWorksheet(worksheet, objectName);
314
- }
259
+ this.loadDataFromWorkbookForObject(workbook, objectName);
315
260
  } catch (error) {
316
- console.warn(`[ExcelDriver] Warning: Failed to load file ${file}:`, (error as Error).message);
261
+ console.warn(`[ExcelDriver] Failed to load ${file}:`, error);
317
262
  }
263
+ this.releaseLock(filePath);
318
264
  }
319
265
  }
320
266
  }
321
267
 
322
268
  /**
323
- * Load data from workbook into memory.
324
- *
325
- * Expected Excel format:
326
- * - First row contains column headers (field names)
327
- * - Subsequent rows contain data records
328
- * - Each worksheet represents one object type
269
+ * Load all data from single-file workbook into memory.
329
270
  */
330
271
  private loadDataFromWorkbook(): void {
331
- this.workbook.eachSheet((worksheet) => {
332
- this.loadDataFromSingleWorksheet(worksheet, worksheet.name);
272
+ this.workbook.worksheets.forEach(worksheet => {
273
+ const objectName = worksheet.name;
274
+ const records = this.parseWorksheet(worksheet);
275
+
276
+ for (const record of records) {
277
+ const id = record.id || record._id;
278
+ const key = `${objectName}:${id}`;
279
+ this.store.set(key, record);
280
+ }
333
281
  });
334
282
  }
335
283
 
336
284
  /**
337
- * Load data from a single worksheet into memory.
285
+ * Load data from workbook for a specific object.
338
286
  */
339
- private loadDataFromSingleWorksheet(worksheet: ExcelJS.Worksheet, objectName: string): void {
340
- const records: any[] = [];
341
-
342
- // Get headers from first row
343
- const headerRow = worksheet.getRow(1);
344
- const headers: string[] = [];
345
- headerRow.eachCell((cell: any, colNumber: number) => {
346
- const headerValue = cell.value;
347
- if (headerValue) {
348
- headers[colNumber - 1] = String(headerValue);
349
- }
350
- });
351
-
352
- // Warn if worksheet has no headers (might be corrupted or wrong format)
353
- if (headers.length === 0 && worksheet.rowCount > 0) {
354
- console.warn(`[ExcelDriver] Warning: Worksheet "${objectName}" has no headers in first row. Skipping.`);
355
- return;
356
- }
357
-
358
- // Skip first row (headers) and read data rows
359
- let rowsProcessed = 0;
360
- let rowsSkipped = 0;
361
-
362
- worksheet.eachRow((row: any, rowNumber: number) => {
363
- if (rowNumber === 1) return; // Skip header row
364
-
365
- const record: any = {};
366
- let hasData = false;
367
-
368
- row.eachCell((cell: any, colNumber: number) => {
369
- const header = headers[colNumber - 1];
370
- if (header) {
371
- record[header] = cell.value;
372
- hasData = true;
373
- }
374
- });
287
+ private loadDataFromWorkbookForObject(workbook: ExcelJS.Workbook, objectName: string): void {
288
+ workbook.worksheets.forEach(worksheet => {
289
+ const records = this.parseWorksheet(worksheet);
375
290
 
376
- // Skip completely empty rows
377
- if (!hasData) {
378
- rowsSkipped++;
379
- return;
380
- }
381
-
382
- // Ensure ID exists
383
- if (!record.id) {
384
- record.id = this.generateId(objectName);
291
+ for (const record of records) {
292
+ const id = record.id || record._id;
293
+ const key = `${objectName}:${id}`;
294
+ this.store.set(key, record);
385
295
  }
386
-
387
- records.push(record);
388
- rowsProcessed++;
389
296
  });
390
-
391
- // Log summary for debugging
392
- if (rowsSkipped > 0) {
393
- console.warn(`[ExcelDriver] Worksheet "${objectName}": Processed ${rowsProcessed} rows, skipped ${rowsSkipped} empty rows`);
394
- }
395
-
396
- this.data.set(objectName, records);
397
- this.updateIdCounter(objectName, records);
398
297
  }
399
298
 
400
299
  /**
401
- * Update ID counter based on existing records.
402
- *
403
- * Attempts to extract counter from auto-generated IDs (format: objectName-timestamp-counter).
404
- * If IDs don't follow this format, counter starts from existing record count.
300
+ * Parse worksheet into array of records.
405
301
  */
406
- private updateIdCounter(objectName: string, records: any[]): void {
407
- let maxCounter = 0;
408
- for (const record of records) {
409
- if (record.id) {
410
- // Try to extract counter from generated IDs (format: objectName-timestamp-counter)
411
- const idStr = String(record.id);
412
- const parts = idStr.split('-');
413
-
414
- // Only parse if it matches the expected auto-generated format
415
- if (parts.length === 3 && parts[0] === objectName && !isNaN(Number(parts[2]))) {
416
- const counter = Number(parts[2]);
417
- if (counter > maxCounter) {
418
- maxCounter = counter;
302
+ private parseWorksheet(worksheet: ExcelJS.Worksheet): any[] {
303
+ const records: any[] = [];
304
+ const headers: string[] = [];
305
+
306
+ worksheet.eachRow((row, rowNumber) => {
307
+ if (rowNumber === 1) {
308
+ row.eachCell((cell) => {
309
+ headers.push(String(cell.value || ''));
310
+ });
311
+ } else {
312
+ const record: any = {};
313
+ row.eachCell((cell, colNumber) => {
314
+ const header = headers[colNumber - 1];
315
+ if (header) {
316
+ record[header] = cell.value;
419
317
  }
318
+ });
319
+ if (Object.keys(record).length > 0) {
320
+ records.push(record);
420
321
  }
421
322
  }
422
- }
423
-
424
- // If no auto-generated IDs found, start from record count to avoid collisions
425
- if (maxCounter === 0 && records.length > 0) {
426
- maxCounter = records.length;
427
- }
323
+ });
428
324
 
429
- this.idCounters.set(objectName, maxCounter);
325
+ return records;
430
326
  }
431
327
 
432
328
  /**
433
329
  * Save workbook to file.
434
330
  */
435
- /**
436
- * Save workbook to file.
437
- * Handles both single-file and file-per-object modes.
438
- */
439
331
  private async saveWorkbook(objectName?: string): Promise<void> {
440
332
  if (this.fileStorageMode === 'single-file') {
441
333
  await this.saveSingleFileWorkbook();
@@ -448,14 +340,13 @@ export class ExcelDriver implements Driver {
448
340
  * Save workbook in single-file mode.
449
341
  */
450
342
  private async saveSingleFileWorkbook(): Promise<void> {
343
+ const dir = path.dirname(this.filePath);
344
+ if (!fs.existsSync(dir)) {
345
+ fs.mkdirSync(dir, { recursive: true });
346
+ }
347
+
348
+ await this.acquireLock(this.filePath);
451
349
  try {
452
- // Ensure directory exists
453
- const dir = path.dirname(this.filePath);
454
- if (!fs.existsSync(dir)) {
455
- fs.mkdirSync(dir, { recursive: true });
456
- }
457
-
458
- // Write workbook to file
459
350
  await this.workbook.xlsx.writeFile(this.filePath);
460
351
  } catch (error) {
461
352
  throw new ObjectQLError({
@@ -463,6 +354,8 @@ export class ExcelDriver implements Driver {
463
354
  message: `Failed to write Excel file: ${this.filePath}`,
464
355
  details: { error: (error as Error).message }
465
356
  });
357
+ } finally {
358
+ this.releaseLock(this.filePath);
466
359
  }
467
360
  }
468
361
 
@@ -479,8 +372,9 @@ export class ExcelDriver implements Driver {
479
372
  });
480
373
  }
481
374
 
375
+ const filePath = path.join(this.filePath, `${objectName}.xlsx`);
376
+ await this.acquireLock(filePath);
482
377
  try {
483
- const filePath = path.join(this.filePath, `${objectName}.xlsx`);
484
378
  await workbook.xlsx.writeFile(filePath);
485
379
  } catch (error) {
486
380
  throw new ObjectQLError({
@@ -488,13 +382,13 @@ export class ExcelDriver implements Driver {
488
382
  message: `Failed to write Excel file for object: ${objectName}`,
489
383
  details: { objectName, error: (error as Error).message }
490
384
  });
385
+ } finally {
386
+ this.releaseLock(filePath);
491
387
  }
492
388
  }
493
389
 
494
390
  /**
495
- * Sync in-memory data to workbook.
496
- *
497
- * Creates or updates a worksheet for the given object type.
391
+ * Sync in-memory data to workbook and save.
498
392
  */
499
393
  private async syncToWorkbook(objectName: string): Promise<void> {
500
394
  if (this.fileStorageMode === 'single-file') {
@@ -508,7 +402,7 @@ export class ExcelDriver implements Driver {
508
402
  * Sync data to worksheet in single-file mode.
509
403
  */
510
404
  private async syncToSingleFileWorkbook(objectName: string): Promise<void> {
511
- const records = this.data.get(objectName) || [];
405
+ const records = this.getObjectRecords(objectName);
512
406
 
513
407
  // Remove existing worksheet if it exists
514
408
  const existingSheet = this.workbook.getWorksheet(objectName);
@@ -518,34 +412,9 @@ export class ExcelDriver implements Driver {
518
412
 
519
413
  // Create new worksheet
520
414
  const worksheet = this.workbook.addWorksheet(objectName);
415
+ this.populateWorksheet(worksheet, records);
521
416
 
522
- if (records.length > 0) {
523
- // Get all unique keys from records to create headers
524
- const headers = new Set<string>();
525
- records.forEach(record => {
526
- Object.keys(record).forEach(key => headers.add(key));
527
- });
528
- const headerArray = Array.from(headers);
529
-
530
- // Add header row
531
- worksheet.addRow(headerArray);
532
-
533
- // Add data rows
534
- records.forEach(record => {
535
- const row = headerArray.map(header => record[header]);
536
- worksheet.addRow(row);
537
- });
538
-
539
- // Auto-fit columns
540
- worksheet.columns.forEach((column: any) => {
541
- if (column.header) {
542
- column.width = Math.max(10, String(column.header).length + 2);
543
- }
544
- });
545
- }
546
-
547
- // Auto-save if enabled
548
- if (this.config.autoSave) {
417
+ if (this.autoSave) {
549
418
  await this.saveWorkbook();
550
419
  }
551
420
  }
@@ -554,9 +423,8 @@ export class ExcelDriver implements Driver {
554
423
  * Sync data to separate file in file-per-object mode.
555
424
  */
556
425
  private async syncToFilePerObjectWorkbook(objectName: string): Promise<void> {
557
- const records = this.data.get(objectName) || [];
426
+ const records = this.getObjectRecords(objectName);
558
427
 
559
- // Get or create workbook for this object
560
428
  let workbook = this.workbooks.get(objectName);
561
429
  if (!workbook) {
562
430
  workbook = new ExcelJS.Workbook();
@@ -568,789 +436,145 @@ export class ExcelDriver implements Driver {
568
436
 
569
437
  // Create new worksheet
570
438
  const worksheet = workbook.addWorksheet(objectName);
439
+ this.populateWorksheet(worksheet, records);
571
440
 
572
- if (records.length > 0) {
573
- // Get all unique keys from records to create headers
574
- const headers = new Set<string>();
575
- records.forEach(record => {
576
- Object.keys(record).forEach(key => headers.add(key));
577
- });
578
- const headerArray = Array.from(headers);
579
-
580
- // Add header row
581
- worksheet.addRow(headerArray);
582
-
583
- // Add data rows
584
- records.forEach(record => {
585
- const row = headerArray.map(header => record[header]);
586
- worksheet.addRow(row);
587
- });
588
-
589
- // Auto-fit columns
590
- worksheet.columns.forEach((column: any) => {
591
- if (column.header) {
592
- column.width = Math.max(10, String(column.header).length + 2);
593
- }
594
- });
595
- }
596
-
597
- // Auto-save if enabled
598
- if (this.config.autoSave) {
441
+ if (this.autoSave) {
599
442
  await this.saveWorkbook(objectName);
600
443
  }
601
444
  }
602
445
 
603
446
  /**
604
- * Find multiple records matching the query criteria.
447
+ * Get all records for an object from memory store.
605
448
  */
606
- async find(objectName: string, query: any = {}, options?: any): Promise<any[]> {
607
- // Normalize query to support both legacy and QueryAST formats
608
- const normalizedQuery = this.normalizeQuery(query);
609
- let results = this.data.get(objectName) || [];
610
-
611
- // Return empty array if no data
612
- if (results.length === 0) {
613
- return [];
614
- }
615
-
616
- // Deep copy to avoid mutations
617
- results = results.map(r => ({ ...r }));
449
+ private getObjectRecords(objectName: string): any[] {
450
+ const records: any[] = [];
451
+ const prefix = `${objectName}:`;
618
452
 
619
- // Apply filters from where clause (QueryAST format)
620
- if (normalizedQuery.where) {
621
- const legacyFilters = this.convertFilterConditionToArray(normalizedQuery.where);
622
- if (legacyFilters) {
623
- results = this.applyFilters(results, legacyFilters);
453
+ for (const [key, value] of this.store.entries()) {
454
+ if (key.startsWith(prefix)) {
455
+ records.push(value);
624
456
  }
625
457
  }
626
458
 
627
- // Apply filters from legacy filters clause
628
- if (normalizedQuery.filters) {
629
- results = this.applyFilters(results, normalizedQuery.filters);
630
- }
631
-
632
- // Apply sorting from orderBy (QueryAST format)
633
- if (normalizedQuery.orderBy && Array.isArray(normalizedQuery.orderBy)) {
634
- results = this.applySort(results, normalizedQuery.orderBy);
635
- }
636
-
637
- // Apply sorting from legacy sort clause
638
- if (normalizedQuery.sort && Array.isArray(normalizedQuery.sort)) {
639
- results = this.applySort(results, normalizedQuery.sort);
640
- }
641
-
642
- // Apply pagination with offset (QueryAST format)
643
- if (normalizedQuery.offset) {
644
- results = results.slice(normalizedQuery.offset);
645
- }
646
-
647
- // Apply pagination with skip (legacy format)
648
- if (normalizedQuery.skip) {
649
- results = results.slice(normalizedQuery.skip);
650
- }
651
-
652
- if (normalizedQuery.limit) {
653
- results = results.slice(0, normalizedQuery.limit);
654
- }
655
-
656
- // Apply field projection
657
- if (normalizedQuery.fields && Array.isArray(normalizedQuery.fields)) {
658
- results = results.map(doc => this.projectFields(doc, normalizedQuery.fields));
659
- }
660
-
661
- return results;
459
+ return records;
662
460
  }
663
461
 
664
462
  /**
665
- * Find a single record by ID or query.
463
+ * Populate worksheet with records.
666
464
  */
667
- async findOne(objectName: string, id: string | number, query?: any, options?: any): Promise<any> {
668
- const records = this.data.get(objectName) || [];
669
-
670
- // If ID is provided, fetch directly
671
- if (id) {
672
- const record = records.find(r => r.id === String(id));
673
- return record ? { ...record } : null;
674
- }
675
-
676
- // If query is provided, use find and return first result
677
- if (query) {
678
- const results = await this.find(objectName, { ...query, limit: 1 }, options);
679
- return results[0] || null;
465
+ private populateWorksheet(worksheet: ExcelJS.Worksheet, records: any[]): void {
466
+ if (records.length === 0) {
467
+ return;
680
468
  }
681
-
682
- return null;
683
- }
684
469
 
685
- /**
686
- * Create a new record.
687
- */
688
- async create(objectName: string, data: any, options?: any): Promise<any> {
689
- // Get or create object data array
690
- if (!this.data.has(objectName)) {
691
- this.data.set(objectName, []);
692
- }
693
-
694
- const records = this.data.get(objectName)!;
695
-
696
- // Generate ID if not provided
697
- const id = data.id || this.generateId(objectName);
698
-
699
- // Check if record already exists
700
- if (records.some(r => r.id === id)) {
701
- throw new ObjectQLError({
702
- code: 'DUPLICATE_RECORD',
703
- message: `Record with id '${id}' already exists in '${objectName}'`,
704
- details: { objectName, id }
705
- });
706
- }
707
-
708
- const now = new Date().toISOString();
709
- const doc = {
710
- id,
711
- ...data,
712
- created_at: data.created_at || now,
713
- updated_at: data.updated_at || now
714
- };
470
+ // Get all unique keys from records to create headers
471
+ const headers = new Set<string>();
472
+ records.forEach(record => {
473
+ Object.keys(record).forEach(key => headers.add(key));
474
+ });
475
+ const headerArray = Array.from(headers);
715
476
 
716
- records.push(doc);
717
- await this.syncToWorkbook(objectName);
477
+ // Add header row
478
+ worksheet.addRow(headerArray);
718
479
 
719
- return { ...doc };
720
- }
721
-
722
- /**
723
- * Update an existing record.
724
- */
725
- async update(objectName: string, id: string | number, data: any, options?: any): Promise<any> {
726
- const records = this.data.get(objectName) || [];
727
- const index = records.findIndex(r => r.id === String(id));
480
+ // Add data rows
481
+ records.forEach(record => {
482
+ const row = headerArray.map(header => record[header]);
483
+ worksheet.addRow(row);
484
+ });
728
485
 
729
- if (index === -1) {
730
- if (this.config.strictMode) {
731
- throw new ObjectQLError({
732
- code: 'RECORD_NOT_FOUND',
733
- message: `Record with id '${id}' not found in '${objectName}'`,
734
- details: { objectName, id }
735
- });
486
+ // Auto-fit columns
487
+ worksheet.columns.forEach((column: any) => {
488
+ if (column.header) {
489
+ column.width = Math.max(10, String(column.header).length + 2);
736
490
  }
737
- return null;
738
- }
739
-
740
- const existing = records[index];
741
- const doc = {
742
- ...existing,
743
- ...data,
744
- id: existing.id, // Preserve ID
745
- created_at: existing.created_at, // Preserve created_at
746
- updated_at: new Date().toISOString()
747
- };
748
-
749
- records[index] = doc;
750
- await this.syncToWorkbook(objectName);
751
-
752
- return { ...doc };
491
+ });
753
492
  }
754
493
 
755
494
  /**
756
- * Delete a record.
495
+ * Simple file locking mechanism.
757
496
  */
758
- async delete(objectName: string, id: string | number, options?: any): Promise<any> {
759
- const records = this.data.get(objectName) || [];
760
- const index = records.findIndex(r => r.id === String(id));
497
+ private async acquireLock(filePath: string): Promise<void> {
498
+ const maxRetries = 10;
499
+ const retryDelay = 100;
761
500
 
762
- if (index === -1) {
763
- if (this.config.strictMode) {
764
- throw new ObjectQLError({
765
- code: 'RECORD_NOT_FOUND',
766
- message: `Record with id '${id}' not found in '${objectName}'`,
767
- details: { objectName, id }
768
- });
501
+ for (let i = 0; i < maxRetries; i++) {
502
+ if (!this.fileLocks.get(filePath)) {
503
+ this.fileLocks.set(filePath, true);
504
+ return;
769
505
  }
770
- return false;
506
+ await new Promise(resolve => setTimeout(resolve, retryDelay));
771
507
  }
772
508
 
773
- records.splice(index, 1);
774
- await this.syncToWorkbook(objectName);
775
-
776
- return true;
777
- }
778
-
779
- /**
780
- * Count records matching filters.
781
- */
782
- async count(objectName: string, filters: any, options?: any): Promise<number> {
783
- const records = this.data.get(objectName) || [];
784
-
785
- // Extract actual filters from query object
786
- let actualFilters = filters;
787
-
788
- // Support QueryAST format with 'where' clause
789
- if (filters && filters.where) {
790
- actualFilters = this.convertFilterConditionToArray(filters.where);
791
- }
792
- // Support legacy format with 'filters' clause
793
- else if (filters && !Array.isArray(filters) && filters.filters) {
794
- actualFilters = filters.filters;
795
- }
796
-
797
- // If no filters or empty object/array, return total count
798
- if (!actualFilters ||
799
- (Array.isArray(actualFilters) && actualFilters.length === 0) ||
800
- (typeof actualFilters === 'object' && !Array.isArray(actualFilters) && Object.keys(actualFilters).length === 0)) {
801
- return records.length;
802
- }
803
-
804
- // Count only records matching filters
805
- return records.filter(record => this.matchesFilters(record, actualFilters)).length;
509
+ throw new ObjectQLError({
510
+ code: 'FILE_LOCK_TIMEOUT',
511
+ message: `Failed to acquire lock for file: ${filePath}`,
512
+ details: { filePath }
513
+ });
806
514
  }
807
515
 
808
516
  /**
809
- * Get distinct values for a field.
517
+ * Release file lock.
810
518
  */
811
- async distinct(objectName: string, field: string, filters?: any, options?: any): Promise<any[]> {
812
- const records = this.data.get(objectName) || [];
813
- const values = new Set<any>();
814
-
815
- for (const record of records) {
816
- if (!filters || this.matchesFilters(record, filters)) {
817
- const value = record[field];
818
- if (value !== undefined && value !== null) {
819
- values.add(value);
820
- }
821
- }
822
- }
823
-
824
- return Array.from(values);
519
+ private releaseLock(filePath: string): void {
520
+ this.fileLocks.delete(filePath);
825
521
  }
826
522
 
827
523
  /**
828
- * Create multiple records at once.
524
+ * Override create to persist to Excel.
829
525
  */
830
- async createMany(objectName: string, data: any[], options?: any): Promise<any> {
831
- const results = [];
832
- for (const item of data) {
833
- const result = await this.create(objectName, item, options);
834
- results.push(result);
835
- }
836
- return results;
526
+ async create(objectName: string, data: any, options?: any): Promise<any> {
527
+ const result = await super.create(objectName, data, options);
528
+ await this.syncToWorkbook(objectName);
529
+ return result;
837
530
  }
838
531
 
839
532
  /**
840
- * Update multiple records matching filters.
533
+ * Override update to persist to Excel.
841
534
  */
842
- async updateMany(objectName: string, filters: any, data: any, options?: any): Promise<any> {
843
- const records = this.data.get(objectName) || [];
844
- let count = 0;
845
-
846
- for (let i = 0; i < records.length; i++) {
847
- if (this.matchesFilters(records[i], filters)) {
848
- records[i] = {
849
- ...records[i],
850
- ...data,
851
- id: records[i].id, // Preserve ID
852
- created_at: records[i].created_at, // Preserve created_at
853
- updated_at: new Date().toISOString()
854
- };
855
- count++;
856
- }
857
- }
858
-
859
- if (count > 0) {
535
+ async update(objectName: string, id: string | number, data: any, options?: any): Promise<any> {
536
+ const result = await super.update(objectName, id, data, options);
537
+ if (result) {
860
538
  await this.syncToWorkbook(objectName);
861
539
  }
862
-
863
- return { modifiedCount: count };
540
+ return result;
864
541
  }
865
542
 
866
543
  /**
867
- * Delete multiple records matching filters.
544
+ * Override delete to persist to Excel.
868
545
  */
869
- async deleteMany(objectName: string, filters: any, options?: any): Promise<any> {
870
- const records = this.data.get(objectName) || [];
871
- const initialLength = records.length;
872
-
873
- const filtered = records.filter(record => !this.matchesFilters(record, filters));
874
- this.data.set(objectName, filtered);
875
-
876
- const deletedCount = initialLength - filtered.length;
877
-
878
- if (deletedCount > 0) {
546
+ async delete(objectName: string, id: string | number, options?: any): Promise<any> {
547
+ const result = await super.delete(objectName, id, options);
548
+ if (result) {
879
549
  await this.syncToWorkbook(objectName);
880
550
  }
881
-
882
- return { deletedCount };
551
+ return result;
883
552
  }
884
553
 
885
554
  /**
886
- * Manually save the workbook to file.
887
- */
888
- /**
889
- * Manually save the workbook to file.
555
+ * Manually save all data to Excel file(s).
890
556
  */
891
557
  async save(): Promise<void> {
892
558
  if (this.fileStorageMode === 'single-file') {
893
- await this.saveWorkbook();
894
- } else {
895
- // Save all object files in file-per-object mode
896
- for (const objectName of this.data.keys()) {
897
- await this.saveWorkbook(objectName);
898
- }
899
- }
900
- }
901
-
902
- /**
903
- * Disconnect (flush any pending writes).
904
- */
905
- async disconnect(): Promise<void> {
906
- if (this.config.autoSave) {
907
- await this.save();
908
- }
909
- }
910
-
911
- /**
912
- * Execute a query using QueryAST (DriverInterface v4.0 method)
913
- *
914
- * This method handles all query operations using the standard QueryAST format
915
- * from @objectstack/spec. It converts the AST to the legacy query format
916
- * and delegates to the existing find() method.
917
- *
918
- * @param ast - The query AST to execute
919
- * @param options - Optional execution options
920
- * @returns Query results with value array and count
921
- */
922
- async executeQuery(ast: QueryAST, options?: any): Promise<{ value: any[]; count?: number }> {
923
- const objectName = ast.object || '';
924
-
925
- // Convert QueryAST to legacy query format
926
- // Note: Convert FilterCondition (MongoDB-like) to array format for excel driver
927
- const legacyQuery: any = {
928
- fields: ast.fields,
929
- filters: this.convertFilterConditionToArray(ast.where),
930
- sort: ast.orderBy?.map((s: SortNode) => [s.field, s.order]),
931
- limit: ast.limit,
932
- skip: ast.offset,
933
- };
934
-
935
- // Use existing find method
936
- const results = await this.find(objectName, legacyQuery, options);
937
-
938
- return {
939
- value: results,
940
- count: results.length
941
- };
942
- }
943
-
944
- /**
945
- * Execute a command (DriverInterface v4.0 method)
946
- *
947
- * This method handles all mutation operations (create, update, delete)
948
- * using a unified command interface.
949
- *
950
- * @param command - The command to execute
951
- * @param options - Optional execution options
952
- * @returns Command execution result
953
- */
954
- async executeCommand(command: Command, options?: any): Promise<CommandResult> {
955
- try {
956
- const cmdOptions = { ...options, ...command.options };
957
-
958
- switch (command.type) {
959
- case 'create':
960
- if (!command.data) {
961
- throw new Error('Create command requires data');
962
- }
963
- const created = await this.create(command.object, command.data, cmdOptions);
964
- return {
965
- success: true,
966
- data: created,
967
- affected: 1
968
- };
969
-
970
- case 'update':
971
- if (!command.id || !command.data) {
972
- throw new Error('Update command requires id and data');
973
- }
974
- const updated = await this.update(command.object, command.id, command.data, cmdOptions);
975
- return {
976
- success: true,
977
- data: updated,
978
- affected: 1
979
- };
980
-
981
- case 'delete':
982
- if (!command.id) {
983
- throw new Error('Delete command requires id');
984
- }
985
- await this.delete(command.object, command.id, cmdOptions);
986
- return {
987
- success: true,
988
- affected: 1
989
- };
990
-
991
- case 'bulkCreate':
992
- if (!command.records || !Array.isArray(command.records)) {
993
- throw new Error('BulkCreate command requires records array');
994
- }
995
- const bulkCreated = [];
996
- for (const record of command.records) {
997
- const created = await this.create(command.object, record, cmdOptions);
998
- bulkCreated.push(created);
999
- }
1000
- return {
1001
- success: true,
1002
- data: bulkCreated,
1003
- affected: command.records.length
1004
- };
1005
-
1006
- case 'bulkUpdate':
1007
- if (!command.updates || !Array.isArray(command.updates)) {
1008
- throw new Error('BulkUpdate command requires updates array');
1009
- }
1010
- const updateResults = [];
1011
- for (const update of command.updates) {
1012
- const result = await this.update(command.object, update.id, update.data, cmdOptions);
1013
- updateResults.push(result);
1014
- }
1015
- return {
1016
- success: true,
1017
- data: updateResults,
1018
- affected: command.updates.length
1019
- };
1020
-
1021
- case 'bulkDelete':
1022
- if (!command.ids || !Array.isArray(command.ids)) {
1023
- throw new Error('BulkDelete command requires ids array');
1024
- }
1025
- let deleted = 0;
1026
- for (const id of command.ids) {
1027
- const result = await this.delete(command.object, id, cmdOptions);
1028
- if (result) deleted++;
1029
- }
1030
- return {
1031
- success: true,
1032
- affected: deleted
1033
- };
1034
-
1035
- default:
1036
- throw new Error(`Unsupported command type: ${(command as any).type}`);
1037
- }
1038
- } catch (error: any) {
1039
- return {
1040
- success: false,
1041
- affected: 0,
1042
- error: error.message || 'Unknown error occurred'
1043
- };
1044
- }
1045
- }
1046
-
1047
- /**
1048
- * Execute raw command (for compatibility)
1049
- *
1050
- * @param command - Command string or object
1051
- * @param parameters - Command parameters
1052
- * @param options - Execution options
1053
- */
1054
- async execute(command: any, parameters?: any[], options?: any): Promise<any> {
1055
- throw new Error('Excel driver does not support raw command execution. Use executeCommand() instead.');
1056
- }
1057
-
1058
- // ========== Helper Methods ==========
1059
-
1060
- /**
1061
- * Convert FilterCondition (MongoDB-like format) to legacy array format.
1062
- * This allows the excel driver to use its existing filter evaluation logic.
1063
- *
1064
- * @param condition - FilterCondition object or legacy array
1065
- * @returns Legacy filter array format
1066
- */
1067
- private convertFilterConditionToArray(condition?: any): any[] | undefined {
1068
- if (!condition) return undefined;
1069
-
1070
- // If already an array, return as-is
1071
- if (Array.isArray(condition)) {
1072
- return condition;
1073
- }
1074
-
1075
- // If it's an object (FilterCondition), convert to array format
1076
- // This is a simplified conversion - a full implementation would need to handle all operators
1077
- const result: any[] = [];
1078
-
1079
- for (const [key, value] of Object.entries(condition)) {
1080
- if (key === '$and' && Array.isArray(value)) {
1081
- // Handle $and: [cond1, cond2, ...]
1082
- for (let i = 0; i < value.length; i++) {
1083
- const converted = this.convertFilterConditionToArray(value[i]);
1084
- if (converted && converted.length > 0) {
1085
- if (result.length > 0) {
1086
- result.push('and');
1087
- }
1088
- result.push(...converted);
1089
- }
1090
- }
1091
- } else if (key === '$or' && Array.isArray(value)) {
1092
- // Handle $or: [cond1, cond2, ...]
1093
- for (let i = 0; i < value.length; i++) {
1094
- const converted = this.convertFilterConditionToArray(value[i]);
1095
- if (converted && converted.length > 0) {
1096
- if (result.length > 0) {
1097
- result.push('or');
1098
- }
1099
- result.push(...converted);
1100
- }
1101
- }
1102
- } else if (key === '$not' && typeof value === 'object') {
1103
- // Handle $not: { condition }
1104
- // Note: NOT is complex to represent in array format, so we skip it for now
1105
- const converted = this.convertFilterConditionToArray(value);
1106
- if (converted) {
1107
- result.push(...converted);
1108
- }
1109
- } else if (typeof value === 'object' && value !== null) {
1110
- // Handle field-level conditions like { field: { $eq: value } }
1111
- const field = key;
1112
- for (const [operator, operandValue] of Object.entries(value)) {
1113
- let op: string;
1114
- switch (operator) {
1115
- case '$eq': op = '='; break;
1116
- case '$ne': op = '!='; break;
1117
- case '$gt': op = '>'; break;
1118
- case '$gte': op = '>='; break;
1119
- case '$lt': op = '<'; break;
1120
- case '$lte': op = '<='; break;
1121
- case '$in': op = 'in'; break;
1122
- case '$nin': op = 'nin'; break;
1123
- case '$regex': op = 'like'; break;
1124
- default: op = '=';
1125
- }
1126
- result.push([field, op, operandValue]);
1127
- }
1128
- } else {
1129
- // Handle simple equality: { field: value }
1130
- result.push([key, '=', value]);
1131
- }
1132
- }
1133
-
1134
- return result.length > 0 ? result : undefined;
1135
- }
1136
-
1137
- /**
1138
- * Normalizes query format to support both legacy UnifiedQuery and QueryAST formats.
1139
- * This ensures backward compatibility while supporting the new @objectstack/spec interface.
1140
- *
1141
- * QueryAST format uses 'top' for limit, 'offset' for skip, and 'orderBy' for sort.
1142
- * QueryAST uses 'where' for filters with MongoDB-like operators.
1143
- * UnifiedQuery uses 'limit', 'skip', 'sort', and 'filters'.
1144
- */
1145
- private normalizeQuery(query: any): any {
1146
- if (!query) return {};
1147
-
1148
- const normalized: any = { ...query };
1149
-
1150
- // Normalize limit/top
1151
- if (normalized.top !== undefined && normalized.limit === undefined) {
1152
- normalized.limit = normalized.top;
1153
- }
1154
-
1155
- // Normalize offset/skip (both are preserved for backward compatibility)
1156
- // The find() method will check both
1157
-
1158
- // Normalize orderBy to sort format if orderBy is present
1159
- if (normalized.orderBy && Array.isArray(normalized.orderBy)) {
1160
- // Check if it's already in the legacy array format [field, order]
1161
- const firstSort = normalized.orderBy[0];
1162
- if (firstSort && typeof firstSort === 'object' && !Array.isArray(firstSort) && firstSort.field) {
1163
- // It's in QueryAST format {field, order}, keep as-is
1164
- // The find() method will handle it
1165
- normalized.orderBy = normalized.orderBy;
1166
- }
1167
- }
1168
-
1169
- // Normalize sort format (legacy)
1170
- if (normalized.sort && Array.isArray(normalized.sort)) {
1171
- // Check if it's already in the array format [field, order]
1172
- const firstSort = normalized.sort[0];
1173
- if (firstSort && typeof firstSort === 'object' && !Array.isArray(firstSort)) {
1174
- // Convert from QueryAST format {field, order} to internal format [field, order]
1175
- normalized.sort = normalized.sort.map((item: any) => [
1176
- item.field,
1177
- item.order || item.direction || item.dir || 'asc'
1178
- ]);
1179
- }
1180
- }
1181
-
1182
- return normalized;
1183
- }
1184
-
1185
- /**
1186
- * Apply filters to an array of records (in-memory filtering).
1187
- */
1188
- private applyFilters(records: any[], filters: any[]): any[] {
1189
- if (!filters || filters.length === 0) {
1190
- return records;
1191
- }
1192
-
1193
- return records.filter(record => this.matchesFilters(record, filters));
1194
- }
1195
-
1196
- /**
1197
- * Check if a single record matches the filter conditions.
1198
- */
1199
- private matchesFilters(record: any, filters: any[]): boolean {
1200
- if (!filters || filters.length === 0) {
1201
- return true;
1202
- }
1203
-
1204
- let conditions: boolean[] = [];
1205
- let operators: string[] = [];
1206
-
1207
- for (const item of filters) {
1208
- if (typeof item === 'string') {
1209
- // Logical operator (and/or)
1210
- operators.push(item.toLowerCase());
1211
- } else if (Array.isArray(item)) {
1212
- const [field, operator, value] = item;
1213
-
1214
- // Handle nested filter groups
1215
- if (typeof field !== 'string') {
1216
- // Nested group - recursively evaluate
1217
- conditions.push(this.matchesFilters(record, item));
1218
- } else {
1219
- // Single condition
1220
- const matches = this.evaluateCondition(record[field], operator, value);
1221
- conditions.push(matches);
1222
- }
559
+ // Sync all objects to single file (collect unique object names first)
560
+ const objectNames = new Set<string>();
561
+ for (const [key] of this.store.entries()) {
562
+ objectNames.add(key.split(':')[0]);
1223
563
  }
1224
- }
1225
-
1226
- // Combine conditions with operators
1227
- if (conditions.length === 0) {
1228
- return true;
1229
- }
1230
-
1231
- let result = conditions[0];
1232
- for (let i = 0; i < operators.length; i++) {
1233
- const op = operators[i];
1234
- const nextCondition = conditions[i + 1];
1235
-
1236
- if (op === 'or') {
1237
- result = result || nextCondition;
1238
- } else { // 'and' or default
1239
- result = result && nextCondition;
564
+ for (const objectName of objectNames) {
565
+ await this.syncToSingleFileWorkbook(objectName);
1240
566
  }
1241
- }
1242
-
1243
- return result;
1244
- }
1245
-
1246
- /**
1247
- * Evaluate a single filter condition.
1248
- */
1249
- private evaluateCondition(fieldValue: any, operator: string, compareValue: any): boolean {
1250
- switch (operator) {
1251
- case '=':
1252
- case '==':
1253
- return fieldValue === compareValue;
1254
- case '!=':
1255
- case '<>':
1256
- return fieldValue !== compareValue;
1257
- case '>':
1258
- return fieldValue > compareValue;
1259
- case '>=':
1260
- return fieldValue >= compareValue;
1261
- case '<':
1262
- return fieldValue < compareValue;
1263
- case '<=':
1264
- return fieldValue <= compareValue;
1265
- case 'in':
1266
- return Array.isArray(compareValue) && compareValue.includes(fieldValue);
1267
- case 'nin':
1268
- case 'not in':
1269
- return Array.isArray(compareValue) && !compareValue.includes(fieldValue);
1270
- case 'contains':
1271
- case 'like':
1272
- return String(fieldValue).toLowerCase().includes(String(compareValue).toLowerCase());
1273
- case 'startswith':
1274
- case 'starts_with':
1275
- return String(fieldValue).toLowerCase().startsWith(String(compareValue).toLowerCase());
1276
- case 'endswith':
1277
- case 'ends_with':
1278
- return String(fieldValue).toLowerCase().endsWith(String(compareValue).toLowerCase());
1279
- case 'between':
1280
- return Array.isArray(compareValue) &&
1281
- fieldValue >= compareValue[0] &&
1282
- fieldValue <= compareValue[1];
1283
- default:
1284
- throw new ObjectQLError({
1285
- code: 'UNSUPPORTED_OPERATOR',
1286
- message: `[ExcelDriver] Unsupported operator: ${operator}`,
1287
- });
1288
- }
1289
- }
1290
-
1291
- /**
1292
- * Apply sorting to an array of records.
1293
- */
1294
- private applySort(records: any[], sort: any[]): any[] {
1295
- const sorted = [...records];
1296
-
1297
- // Apply sorts in reverse order for correct precedence
1298
- for (let i = sort.length - 1; i >= 0; i--) {
1299
- const sortItem = sort[i];
1300
-
1301
- let field: string;
1302
- let direction: string;
1303
-
1304
- if (Array.isArray(sortItem)) {
1305
- [field, direction] = sortItem;
1306
- } else if (typeof sortItem === 'object') {
1307
- field = sortItem.field;
1308
- direction = sortItem.order || sortItem.direction || sortItem.dir || 'asc';
1309
- } else {
1310
- continue;
567
+ await this.saveWorkbook();
568
+ } else {
569
+ // Save all object files
570
+ const objectNames = new Set<string>();
571
+ for (const [key] of this.store.entries()) {
572
+ objectNames.add(key.split(':')[0]);
1311
573
  }
1312
-
1313
- sorted.sort((a, b) => {
1314
- const aVal = a[field];
1315
- const bVal = b[field];
1316
-
1317
- // Handle null/undefined
1318
- if (aVal == null && bVal == null) return 0;
1319
- if (aVal == null) return 1;
1320
- if (bVal == null) return -1;
1321
-
1322
- // Compare values
1323
- if (aVal < bVal) return direction === 'asc' ? -1 : 1;
1324
- if (aVal > bVal) return direction === 'asc' ? 1 : -1;
1325
- return 0;
1326
- });
1327
- }
1328
-
1329
- return sorted;
1330
- }
1331
-
1332
- /**
1333
- * Project specific fields from a document.
1334
- */
1335
- private projectFields(doc: any, fields: string[]): any {
1336
- const result: any = {};
1337
- for (const field of fields) {
1338
- if (doc[field] !== undefined) {
1339
- result[field] = doc[field];
574
+ for (const objectName of objectNames) {
575
+ await this.syncToFilePerObjectWorkbook(objectName);
576
+ await this.saveWorkbook(objectName);
1340
577
  }
1341
578
  }
1342
- return result;
1343
- }
1344
-
1345
- /**
1346
- * Generate a unique ID for a record.
1347
- */
1348
- private generateId(objectName: string): string {
1349
- const counter = (this.idCounters.get(objectName) || 0) + 1;
1350
- this.idCounters.set(objectName, counter);
1351
-
1352
- // Use timestamp + counter for better uniqueness
1353
- const timestamp = Date.now();
1354
- return `${objectName}-${timestamp}-${counter}`;
1355
579
  }
1356
580
  }