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