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