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