@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/.turbo/turbo-build.log +4 -0
- package/CHANGELOG.md +31 -0
- package/dist/index.d.ts +43 -219
- package/dist/index.js +202 -810
- package/dist/index.js.map +1 -1
- package/jest.config.js +14 -1
- package/package.json +4 -3
- package/src/index.ts +218 -934
- package/test/index.test.ts +82 -30
- package/tsconfig.tsbuildinfo +1 -1
package/src/index.ts
CHANGED
|
@@ -1,8 +1,5 @@
|
|
|
1
|
-
import { Data
|
|
2
|
-
type
|
|
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
|
|
18
|
-
*
|
|
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
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
* -
|
|
26
|
-
* -
|
|
27
|
-
* -
|
|
28
|
-
* -
|
|
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
|
-
|
|
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
|
-
*
|
|
101
|
-
*
|
|
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
|
|
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
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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 =
|
|
146
|
-
this.
|
|
147
|
-
this.
|
|
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
|
-
|
|
182
|
-
|
|
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
|
-
|
|
160
|
+
const dir = path.dirname(this.filePath);
|
|
161
|
+
return fs.existsSync(dir) || this.createIfMissing;
|
|
193
162
|
} else {
|
|
194
|
-
|
|
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
|
|
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
|
|
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
|
|
200
|
+
detailedMessage += ' - Permission denied';
|
|
247
201
|
} else if (errorMessage.includes('EBUSY')) {
|
|
248
|
-
detailedMessage += ' - File is locked by another process
|
|
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
|
-
|
|
261
|
-
|
|
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
|
|
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.
|
|
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')
|
|
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]
|
|
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.
|
|
333
|
-
|
|
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
|
|
285
|
+
* Load data from workbook for a specific object.
|
|
339
286
|
*/
|
|
340
|
-
private
|
|
341
|
-
|
|
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
|
-
|
|
370
|
-
const
|
|
371
|
-
|
|
372
|
-
|
|
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
|
-
*
|
|
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
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
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
|
-
|
|
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.
|
|
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 (
|
|
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.
|
|
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 (
|
|
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
|
-
*
|
|
447
|
+
* Get all records for an object from memory store.
|
|
606
448
|
*/
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
const
|
|
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
|
-
|
|
639
|
-
|
|
640
|
-
|
|
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
|
|
459
|
+
return records;
|
|
644
460
|
}
|
|
645
461
|
|
|
646
462
|
/**
|
|
647
|
-
*
|
|
463
|
+
* Populate worksheet with records.
|
|
648
464
|
*/
|
|
649
|
-
|
|
650
|
-
|
|
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
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
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
|
-
|
|
699
|
-
|
|
477
|
+
// Add header row
|
|
478
|
+
worksheet.addRow(headerArray);
|
|
700
479
|
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
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
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
495
|
+
* Simple file locking mechanism.
|
|
739
496
|
*/
|
|
740
|
-
async
|
|
741
|
-
const
|
|
742
|
-
const
|
|
497
|
+
private async acquireLock(filePath: string): Promise<void> {
|
|
498
|
+
const maxRetries = 10;
|
|
499
|
+
const retryDelay = 100;
|
|
743
500
|
|
|
744
|
-
|
|
745
|
-
if (this.
|
|
746
|
-
|
|
747
|
-
|
|
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
|
-
|
|
506
|
+
await new Promise(resolve => setTimeout(resolve, retryDelay));
|
|
753
507
|
}
|
|
754
508
|
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
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
|
-
*
|
|
517
|
+
* Release file lock.
|
|
786
518
|
*/
|
|
787
|
-
|
|
788
|
-
|
|
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
|
-
*
|
|
524
|
+
* Override create to persist to Excel.
|
|
805
525
|
*/
|
|
806
|
-
async
|
|
807
|
-
const
|
|
808
|
-
|
|
809
|
-
|
|
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
|
-
*
|
|
533
|
+
* Override update to persist to Excel.
|
|
817
534
|
*/
|
|
818
|
-
async
|
|
819
|
-
const
|
|
820
|
-
|
|
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
|
-
*
|
|
544
|
+
* Override delete to persist to Excel.
|
|
844
545
|
*/
|
|
845
|
-
async
|
|
846
|
-
const
|
|
847
|
-
|
|
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
|
|
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
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1254
|
-
|
|
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
|
}
|