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