@wowoengine/sawitdb 2.5.0 → 2.6.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.
Files changed (37) hide show
  1. package/README.md +72 -16
  2. package/bin/sawit-server.js +5 -1
  3. package/cli/benchmark.js +292 -119
  4. package/cli/local.js +26 -11
  5. package/cli/remote.js +26 -11
  6. package/cli/test.js +110 -72
  7. package/cli/test_security.js +140 -0
  8. package/docs/DB Event.md +32 -0
  9. package/docs/index.html +125 -17
  10. package/package.json +10 -7
  11. package/src/SawitClient.js +122 -98
  12. package/src/SawitServer.js +77 -464
  13. package/src/SawitWorker.js +55 -0
  14. package/src/WowoEngine.js +245 -824
  15. package/src/modules/BTreeIndex.js +54 -24
  16. package/src/modules/ClusterManager.js +78 -0
  17. package/src/modules/Env.js +33 -0
  18. package/src/modules/Pager.js +12 -9
  19. package/src/modules/QueryParser.js +233 -48
  20. package/src/modules/ThreadManager.js +84 -0
  21. package/src/modules/ThreadPool.js +154 -0
  22. package/src/server/DatabaseRegistry.js +92 -0
  23. package/src/server/auth/AuthManager.js +92 -0
  24. package/src/server/router/RequestRouter.js +278 -0
  25. package/src/server/session/ClientSession.js +19 -0
  26. package/src/services/IndexManager.js +183 -0
  27. package/src/services/QueryExecutor.js +11 -0
  28. package/src/services/TableManager.js +162 -0
  29. package/src/services/event/DBEvent.js +61 -0
  30. package/src/services/event/DBEventHandler.js +39 -0
  31. package/src/services/executors/AggregateExecutor.js +153 -0
  32. package/src/services/executors/DeleteExecutor.js +134 -0
  33. package/src/services/executors/InsertExecutor.js +113 -0
  34. package/src/services/executors/SelectExecutor.js +130 -0
  35. package/src/services/executors/UpdateExecutor.js +156 -0
  36. package/src/services/logic/ConditionEvaluator.js +75 -0
  37. package/src/services/logic/JoinProcessor.js +230 -0
package/src/WowoEngine.js CHANGED
@@ -1,15 +1,34 @@
1
1
  const Pager = require('./modules/Pager');
2
2
  const QueryParser = require('./modules/QueryParser');
3
- const BTreeIndex = require('./modules/BTreeIndex');
4
3
  const WAL = require('./modules/WAL');
4
+ const DBEventHandler = require("./services/event/DBEventHandler");
5
+ const DBEvent = require("./services/event/DBEvent");
6
+
7
+ // Services
8
+ const TableManager = require('./services/TableManager');
9
+ const IndexManager = require('./services/IndexManager');
10
+ const ConditionEvaluator = require('./services/logic/ConditionEvaluator');
11
+
12
+ // Executors
13
+ const SelectExecutor = require('./services/executors/SelectExecutor');
14
+ const InsertExecutor = require('./services/executors/InsertExecutor');
15
+ const DeleteExecutor = require('./services/executors/DeleteExecutor');
16
+ const UpdateExecutor = require('./services/executors/UpdateExecutor');
17
+ const AggregateExecutor = require('./services/executors/AggregateExecutor');
5
18
 
6
19
  /**
7
20
  * SawitDB implements the Logic over the Pager
21
+ * Refactored to use modular services and executors.
8
22
  */
9
23
  class SawitDB {
10
24
  constructor(filePath, options = {}) {
11
25
  // WAL: Optional crash safety (backward compatible - disabled by default)
12
26
  this.wal = options.wal ? new WAL(filePath, options.wal) : null;
27
+ this.dbevent = options.dbevent ? options.dbevent : new DBEventHandler();
28
+
29
+ if (!this.dbevent instanceof DBEvent) {
30
+ console.error(`dbevent is not instanceof DBEvent`);
31
+ }
13
32
 
14
33
  // Recovery: Replay WAL if exists
15
34
  if (this.wal && this.wal.enabled) {
@@ -23,56 +42,38 @@ class SawitDB {
23
42
  this.indexes = new Map(); // Map of 'tableName.fieldName' -> BTreeIndex
24
43
  this.parser = new QueryParser();
25
44
 
26
- // YEAR OF THE LINUX DESKTOP - just kidding.
27
45
  // CACHE: Simple LRU for Parsed Queries
28
46
  this.queryCache = new Map();
29
47
  this.queryCacheLimit = 1000;
30
48
 
49
+ // Initialize Services
50
+ this.tableManager = new TableManager(this);
51
+ this.indexManager = new IndexManager(this);
52
+ this.conditionEvaluator = new ConditionEvaluator();
53
+
54
+ // Initialize Executors
55
+ this.selectExecutor = new SelectExecutor(this);
56
+ this.insertExecutor = new InsertExecutor(this);
57
+ this.deleteExecutor = new DeleteExecutor(this);
58
+ this.updateExecutor = new UpdateExecutor(this);
59
+ this.aggregateExecutor = new AggregateExecutor(this);
60
+
31
61
  // PERSISTENCE: Initialize System Tables
32
62
  this._initSystem();
33
63
  }
34
64
 
35
65
  _initSystem() {
36
66
  // Check if _indexes table exists, if not create it
37
- if (!this._findTableEntry('_indexes')) {
67
+ if (!this.tableManager.findTableEntry('_indexes')) {
38
68
  try {
39
- this._createTable('_indexes');
69
+ this.tableManager.createTable('_indexes', true); // true = system table
40
70
  } catch (e) {
41
71
  // Ignore if it effectively exists or concurrency issue
42
72
  }
43
73
  }
44
74
 
45
75
  // Load Indexes
46
- this._loadIndexes();
47
- }
48
-
49
- _loadIndexes() {
50
- const indexRecords = this._select('_indexes', null);
51
- for (const rec of indexRecords) {
52
- const table = rec.table;
53
- const field = rec.field;
54
- const indexKey = `${table}.${field}`;
55
-
56
- if (!this.indexes.has(indexKey)) {
57
- // Rebuild Index in Memory
58
- const index = new BTreeIndex();
59
- index.name = indexKey;
60
- index.keyField = field;
61
-
62
- // Populate Index
63
- try {
64
- const allRecords = this._select(table, null);
65
- for (const record of allRecords) {
66
- if (record.hasOwnProperty(field)) {
67
- index.insert(record[field], record);
68
- }
69
- }
70
- this.indexes.set(indexKey, index);
71
- } catch (e) {
72
- console.error(`Failed to rebuild index ${indexKey}: ${e.message}`);
73
- }
74
- }
75
- }
76
+ this.indexManager.loadIndexes();
76
77
  }
77
78
 
78
79
  close() {
@@ -85,44 +86,42 @@ class SawitDB {
85
86
  }
86
87
  }
87
88
 
88
- query(queryString, params) {
89
- if (!this.pager) return "Error: Database is closed.";
89
+ /**
90
+ * Shallow clone a command object for cache retrieval
91
+ * Faster than JSON.parse(JSON.stringify()) for simple objects
92
+ */
93
+ _shallowCloneCmd(cmd) {
94
+ const clone = { ...cmd };
95
+ // Deep clone arrays (criteria, joins, cols, sort)
96
+ if (cmd.criteria) clone.criteria = { ...cmd.criteria };
97
+ if (cmd.joins) clone.joins = cmd.joins.map(j => ({ ...j, on: { ...j.on } }));
98
+ if (cmd.cols) clone.cols = [...cmd.cols];
99
+ if (cmd.sort) clone.sort = { ...cmd.sort };
100
+ if (cmd.values) clone.values = { ...cmd.values };
101
+ return clone;
102
+ }
90
103
 
104
+ query(queryString, params) {
91
105
  if (!this.pager) return "Error: Database is closed.";
92
106
 
93
- // QUERY CACHE
107
+ // QUERY CACHE - Optimized with shallow clone
94
108
  let cmd;
95
- if (this.queryCache.has(queryString)) {
96
- // Clone to avoid mutation issues if cmd is modified later (bind params currently mutates)
97
- // Ideally we store "Plan" and "Params" separate, but parser returns bound object.
98
- // Wait, parser.bindParameters happens inside parse if params provided.
99
- // If params provided, caching key must include params? No, that defeats point.
100
- // We should cache the UNBOUND command, then bind.
101
- // But parser.parse does both.
102
- // Refactor: parse(query) -> cmd. bind(cmd, params) -> readyCmd.
103
- // Since we can't easily refactor parser signature safely without check,
104
- // let's cache only if no params OR blindly cache and hope bind handles it?
105
- // Current parser.parse takes params.
106
- // We will optimize: Use cache only if key matches.
107
- // If params exist, we can't blindly reuse result from cache if it was bound to different params.
108
- // Strategy: Cache raw tokens/structure?
109
- // Better: Parser.parse(sql) (no params) -> Cache. Then bind.
110
- // We need to change how we call parser.
111
- }
112
-
113
- // OPTIMIZATION: Split Parse and Bind
109
+ this.queryString = queryString;
114
110
  const cacheKey = queryString;
111
+
115
112
  if (this.queryCache.has(cacheKey) && !params) {
116
- cmd = JSON.parse(JSON.stringify(this.queryCache.get(cacheKey))); // Deep clone simple object
113
+ const cached = this.queryCache.get(cacheKey);
114
+ cmd = this._shallowCloneCmd(cached);
115
+ this.queryCache.delete(cacheKey);
116
+ this.queryCache.set(cacheKey, cached);
117
117
  } else {
118
- // Parse without params first to get template
119
118
  const templateCmd = this.parser.parse(queryString);
120
119
  if (templateCmd.type !== 'ERROR') {
121
- // Clone for cache
122
120
  if (!params) {
123
- this.queryCache.set(cacheKey, JSON.parse(JSON.stringify(templateCmd)));
124
- if (this.queryCache.size > this.queryCacheLimit) {
125
- this.queryCache.delete(this.queryCache.keys().next().value);
121
+ this.queryCache.set(cacheKey, templateCmd);
122
+ while (this.queryCache.size > this.queryCacheLimit) {
123
+ const firstKey = this.queryCache.keys().next().value;
124
+ this.queryCache.delete(firstKey);
126
125
  }
127
126
  }
128
127
  cmd = templateCmd;
@@ -130,56 +129,47 @@ class SawitDB {
130
129
  return `Error: ${templateCmd.message}`;
131
130
  }
132
131
 
133
- // Bind now if params exist
134
132
  if (params) {
135
133
  this.parser._bindParameters(cmd, params);
136
134
  }
137
135
  }
138
136
 
139
- // Re-check error type just in case
140
137
  if (cmd.type === 'ERROR') return `Error: ${cmd.message}`;
141
- // const cmd = this.parser.parse(queryString, params);
142
138
 
143
139
  try {
144
140
  switch (cmd.type) {
145
141
  case 'CREATE_TABLE':
146
- return this._createTable(cmd.table);
142
+ return this.tableManager.createTable(cmd.table);
147
143
 
148
144
  case 'SHOW_TABLES':
149
- return this._showTables();
145
+ return this.tableManager.showTables();
150
146
 
151
147
  case 'SHOW_INDEXES':
152
- return this._showIndexes(cmd.table); // cmd.table can be null
148
+ return this.indexManager.showIndexes(cmd.table);
153
149
 
154
150
  case 'INSERT':
155
- return this._insert(cmd.table, cmd.data);
151
+ return this.insertExecutor.execute(cmd);
156
152
 
157
153
  case 'SELECT':
158
- // Map generic generic Select Logic
159
- const rows = this._select(cmd.table, cmd.criteria, cmd.sort, cmd.limit, cmd.offset, cmd.joins);
160
-
161
- if (cmd.cols.length === 1 && cmd.cols[0] === '*') return rows;
162
-
163
- return rows.map(r => {
164
- const newRow = {};
165
- cmd.cols.forEach(c => newRow[c] = r[c] !== undefined ? r[c] : null);
166
- return newRow;
167
- });
154
+ return this.selectExecutor.execute(cmd);
168
155
 
169
156
  case 'DELETE':
170
- return this._delete(cmd.table, cmd.criteria);
157
+ return this.deleteExecutor.execute(cmd);
171
158
 
172
159
  case 'UPDATE':
173
- return this._update(cmd.table, cmd.updates, cmd.criteria);
160
+ return this.updateExecutor.execute(cmd);
174
161
 
175
162
  case 'DROP_TABLE':
176
- return this._dropTable(cmd.table);
163
+ return this.tableManager.dropTable(cmd.table);
177
164
 
178
165
  case 'CREATE_INDEX':
179
- return this._createIndex(cmd.table, cmd.field);
166
+ return this.indexManager.createIndex(cmd.table, cmd.field);
180
167
 
181
168
  case 'AGGREGATE':
182
- return this._aggregate(cmd.table, cmd.func, cmd.field, cmd.criteria, cmd.groupBy);
169
+ return this.aggregateExecutor.execute(cmd);
170
+
171
+ case 'EXPLAIN':
172
+ return this._explain(cmd.innerCommand);
183
173
 
184
174
  default:
185
175
  return `Perintah tidak dikenal atau belum diimplementasikan di Engine Refactor.`;
@@ -189,428 +179,27 @@ class SawitDB {
189
179
  }
190
180
  }
191
181
 
192
- // --- Core Logic ---
193
-
194
- // --- Core Logic ---
182
+ // --- Core Data Access (Low Level) ---
183
+ // Kept here as it's the fundamental connection between Pager and Logic
184
+ // Could eventually move to TableScanner service
195
185
 
186
+ // Backward compatibility wrapper for old internal calls (if any remain)
196
187
  _findTableEntry(name) {
197
- const p0 = this.pager.readPage(0);
198
- const numTables = p0.readUInt32LE(8);
199
- let offset = 12;
200
-
201
- for (let i = 0; i < numTables; i++) {
202
- const tName = p0.toString('utf8', offset, offset + 32).replace(/\0/g, '');
203
- if (tName === name) {
204
- return {
205
- index: i,
206
- offset: offset,
207
- startPage: p0.readUInt32LE(offset + 32),
208
- lastPage: p0.readUInt32LE(offset + 36)
209
- };
210
- }
211
- offset += 40;
212
- }
213
- return null;
214
- }
215
-
216
- _showTables() {
217
- const p0 = this.pager.readPage(0);
218
- const numTables = p0.readUInt32LE(8);
219
- const tables = [];
220
- let offset = 12;
221
- for (let i = 0; i < numTables; i++) {
222
- const tName = p0.toString('utf8', offset, offset + 32).replace(/\0/g, '');
223
- if (!tName.startsWith('_')) { // Hide system tables
224
- tables.push(tName);
225
- }
226
- offset += 40;
227
- }
228
- return tables;
229
- }
230
-
231
- _createTable(name) {
232
- if (!name) throw new Error("Nama kebun tidak boleh kosong");
233
- if (name.length > 32) throw new Error("Nama kebun max 32 karakter");
234
- if (this._findTableEntry(name)) return `Kebun '${name}' sudah ada.`;
235
-
236
- const p0 = this.pager.readPage(0);
237
- const numTables = p0.readUInt32LE(8);
238
- let offset = 12 + (numTables * 40);
239
- if (offset + 40 > Pager.PAGE_SIZE) throw new Error("Lahan penuh (Page 0 full)");
240
-
241
- const newPageId = this.pager.allocPage();
242
-
243
- const nameBuf = Buffer.alloc(32);
244
- nameBuf.write(name);
245
- nameBuf.copy(p0, offset);
246
-
247
- p0.writeUInt32LE(newPageId, offset + 32);
248
- p0.writeUInt32LE(newPageId, offset + 36);
249
- p0.writeUInt32LE(numTables + 1, 8);
250
-
251
- this.pager.writePage(0, p0);
252
- return `Kebun '${name}' telah dibuka.`;
253
- }
254
-
255
- _dropTable(name) {
256
- if (name === '_indexes') return "Tidak boleh membakar catatan sistem.";
257
-
258
- const entry = this._findTableEntry(name);
259
- if (!entry) return `Kebun '${name}' tidak ditemukan.`;
260
-
261
- // Remove associated indexes
262
- const toRemove = [];
263
- for (const key of this.indexes.keys()) {
264
- if (key.startsWith(name + '.')) {
265
- toRemove.push(key);
266
- }
267
- }
268
-
269
- // Remove from memory
270
- toRemove.forEach(key => this.indexes.delete(key));
271
-
272
- // Remove from _indexes table
273
- try {
274
- this._delete('_indexes', {
275
- type: 'compound',
276
- logic: 'AND',
277
- conditions: [
278
- { key: 'table', op: '=', val: name }
279
- ]
280
- });
281
- } catch (e) { /* Ignore if fails, maybe recursive? No, _delete uses basic ops */ }
282
-
283
-
284
- const p0 = this.pager.readPage(0);
285
- const numTables = p0.readUInt32LE(8);
286
-
287
- // Move last entry to this spot to fill gap
288
- if (numTables > 1 && entry.index < numTables - 1) {
289
- const lastOffset = 12 + ((numTables - 1) * 40);
290
- const lastEntryBuf = p0.slice(lastOffset, lastOffset + 40);
291
- lastEntryBuf.copy(p0, entry.offset);
292
- }
293
-
294
- // Clear last spot
295
- const lastOffset = 12 + ((numTables - 1) * 40);
296
- p0.fill(0, lastOffset, lastOffset + 40);
297
-
298
- p0.writeUInt32LE(numTables - 1, 8);
299
- this.pager.writePage(0, p0);
300
-
301
- return `Kebun '${name}' telah dibakar (Drop).`;
302
- }
303
-
304
- _updateTableLastPage(name, newLastPageId) {
305
- const entry = this._findTableEntry(name);
306
- if (!entry) throw new Error("Internal Error: Table missing for update");
307
-
308
- // Update Disk/Page 0
309
- const p0 = this.pager.readPage(0);
310
- p0.writeUInt32LE(newLastPageId, entry.offset + 36);
311
- this.pager.writePage(0, p0);
312
- }
313
-
314
- _insert(table, data) {
315
- if (!data || Object.keys(data).length === 0) {
316
- throw new Error("Data kosong / fiktif? Ini melanggar integritas (Korupsi Data).");
317
- }
318
- return this._insertMany(table, [data]);
319
- }
320
-
321
- // NEW: Batch Insert for High Performance (50k+ TPS)
322
- _insertMany(table, dataArray) {
323
- if (!dataArray || dataArray.length === 0) return "Tidak ada bibit untuk ditanam.";
324
-
325
- const entry = this._findTableEntry(table);
326
- if (!entry) throw new Error(`Kebun '${table}' tidak ditemukan.`);
327
-
328
- let currentPageId = entry.lastPage;
329
- let pData = this.pager.readPage(currentPageId);
330
- let freeOffset = pData.readUInt16LE(6);
331
- let count = pData.readUInt16LE(4);
332
- let startPageChanged = false;
333
-
334
- for (const data of dataArray) {
335
- const dataStr = JSON.stringify(data);
336
- const dataBuf = Buffer.from(dataStr, 'utf8');
337
- const recordLen = dataBuf.length;
338
- const totalLen = 2 + recordLen;
339
-
340
- // Check if fits
341
- if (freeOffset + totalLen > Pager.PAGE_SIZE) {
342
- // Determine new page ID (predictive or alloc)
343
- // allocPage reads/writes Page 0, which is expensive in loop.
344
- // Optimally we should batch Page 0 update too, but Pager.allocPage handles it.
345
- // For now rely on Pager caching Page 0.
346
-
347
- // Write current full page
348
- pData.writeUInt16LE(count, 4);
349
- pData.writeUInt16LE(freeOffset, 6);
350
- this.pager.writePage(currentPageId, pData);
351
-
352
- const newPageId = this.pager.allocPage();
353
-
354
- // Link old page to new
355
- pData.writeUInt32LE(newPageId, 0);
356
- this.pager.writePage(currentPageId, pData); // Rewrite link
357
-
358
- currentPageId = newPageId;
359
- pData = this.pager.readPage(currentPageId);
360
- freeOffset = pData.readUInt16LE(6);
361
- count = pData.readUInt16LE(4);
362
- startPageChanged = true;
363
- }
364
-
365
-
366
- pData.writeUInt16LE(recordLen, freeOffset);
367
- dataBuf.copy(pData, freeOffset + 2);
368
- freeOffset += totalLen;
369
- count++;
370
-
371
- // Inject Page Hint for Index
372
- Object.defineProperty(data, '_pageId', {
373
- value: currentPageId,
374
- enumerable: false,
375
- writable: true
376
- });
377
-
378
- // Index update (can be batched later if needed, but BTree is fast)
379
- if (table !== '_indexes') {
380
- this._updateIndexes(table, data, null);
381
- }
382
- }
383
-
384
- // Final write
385
- pData.writeUInt16LE(count, 4);
386
- pData.writeUInt16LE(freeOffset, 6);
387
- this.pager.writePage(currentPageId, pData);
388
-
389
- if (startPageChanged) {
390
- this._updateTableLastPage(table, currentPageId);
391
- }
392
-
393
- return `${dataArray.length} bibit tertanam.`;
394
- }
395
-
396
- _updateIndexes(table, newObj, oldObj) {
397
- // If oldObj is null, it's an INSERT. If newObj is null, it's a DELETE. Both? Update.
398
-
399
- for (const [indexKey, index] of this.indexes) {
400
- const [tbl, field] = indexKey.split('.');
401
- if (tbl !== table) continue; // Wrong table
402
-
403
- // 1. Remove old value from index (if exists and changed)
404
- if (oldObj && oldObj.hasOwnProperty(field)) {
405
- // Only remove if value changed OR it's a delete (newObj is null)
406
- // If update, check if value diff
407
- if (!newObj || newObj[field] !== oldObj[field]) {
408
- index.delete(oldObj[field]);
409
- }
410
- }
411
-
412
- // 2. Insert new value (if exists)
413
- if (newObj && newObj.hasOwnProperty(field)) {
414
- // Only insert if it's new OR value changed
415
- if (!oldObj || newObj[field] !== oldObj[field]) {
416
- index.insert(newObj[field], newObj);
417
- }
418
- }
419
- }
188
+ return this.tableManager.findTableEntry(name);
420
189
  }
421
190
 
191
+ // Backward compatibility wrapper for internal calls
422
192
  _checkMatch(obj, criteria) {
423
- if (!criteria) return true;
424
-
425
- if (criteria.type === 'compound') {
426
- let result = (criteria.logic === 'OR') ? false : true;
427
-
428
- for (let i = 0; i < criteria.conditions.length; i++) {
429
- const cond = criteria.conditions[i];
430
- const matches = (cond.type === 'compound')
431
- ? this._checkMatch(obj, cond)
432
- : this._checkSingleCondition(obj, cond);
433
-
434
- if (criteria.logic === 'OR') {
435
- result = result || matches;
436
- if (result) return true; // Short circuit
437
- } else {
438
- result = result && matches;
439
- if (!result) return false; // Short circuit
440
- }
441
- }
442
- return result;
443
- }
444
-
445
- return this._checkSingleCondition(obj, criteria);
193
+ return this.conditionEvaluator.checkMatch(obj, criteria);
446
194
  }
447
195
 
448
- _checkSingleCondition(obj, criteria) {
449
- const val = obj[criteria.key];
450
- const target = criteria.val;
451
- switch (criteria.op) {
452
- case '=': return val == target;
453
- case '!=': return val != target;
454
- case '>': return val > target;
455
- case '<': return val < target;
456
- case '>=': return val >= target;
457
- case '<=': return val <= target;
458
- case 'IN': return Array.isArray(target) && target.includes(val);
459
- case 'NOT IN': return Array.isArray(target) && !target.includes(val);
460
- case 'LIKE':
461
- const regexStr = '^' + target.replace(/%/g, '.*') + '$';
462
- const re = new RegExp(regexStr, 'i');
463
- return re.test(String(val));
464
- case 'BETWEEN':
465
- return val >= target[0] && val <= target[1];
466
- case 'IS NULL':
467
- return val === null || val === undefined;
468
- case 'IS NOT NULL':
469
- return val !== null && val !== undefined;
470
- default: return false;
471
- }
472
- }
473
-
474
- _select(table, criteria, sort, limit, offsetCount, joins) {
475
- const entry = this._findTableEntry(table);
476
- if (!entry) throw new Error(`Kebun '${table}' tidak ditemukan.`);
477
-
478
- let results = [];
479
-
480
- if (joins && joins.length > 0) {
481
- // ... (Existing Join Logic - Unchanged but ensure recursion safe)
482
- // 1. Scan Main Table
483
- let currentRows = this._scanTable(entry, null).map(row => {
484
- const newRow = { ...row };
485
- for (const k in row) {
486
- newRow[`${table}.${k}`] = row[k];
487
- }
488
- return newRow;
489
- });
490
-
491
- // 2. Perform Joins
492
- // 2. Perform Joins
493
- for (const join of joins) {
494
- const joinEntry = this._findTableEntry(join.table);
495
- if (!joinEntry) throw new Error(`Kebun '${join.table}' tidak ditemukan.`);
496
-
497
- // OPTIMIZATION: Hash Join for Equi-Joins (op === '=')
498
- // O(M+N) instead of O(M*N)
499
- let useHashJoin = false;
500
- if (join.on.op === '=') useHashJoin = true;
501
-
502
- const nextRows = [];
503
-
504
- if (useHashJoin) {
505
- // Build Hash Map of Right Table
506
- // Key = val, Value = [rows]
507
- const joinMap = new Map();
508
- // We need to scan right table.
509
- // Optimization: If criteria on right table exists, filter here? No complex logic yet.
510
- const joinRows = this._scanTable(joinEntry, null);
511
-
512
- for (const row of joinRows) {
513
- // Fix: Strip prefix if present in join.on.right
514
- let rightKey = join.on.right;
515
- if (rightKey.startsWith(join.table + '.')) {
516
- rightKey = rightKey.substring(join.table.length + 1);
517
- }
518
- const val = row[rightKey]; // e.g. 'lokasi_ref'
519
- if (val === undefined || val === null) continue;
520
-
521
- if (!joinMap.has(val)) joinMap.set(val, []);
522
- joinMap.get(val).push(row);
523
- }
524
-
525
- // Probe with Left Table (currentRows)
526
- for (const leftRow of currentRows) {
527
- const lVal = leftRow[join.on.left]; // e.g. 'user_id'
528
- if (joinMap.has(lVal)) {
529
- const matches = joinMap.get(lVal);
530
- for (const rightRow of matches) {
531
- const rightRowPrefixed = { ...rightRow }; // Clone needed?
532
- // Prefixing
533
- const prefixed = {};
534
- for (const k in rightRow) prefixed[`${join.table}.${k}`] = rightRow[k];
535
- nextRows.push({ ...leftRow, ...prefixed });
536
- }
537
- }
538
- }
539
-
540
- } else {
541
- // Fallback to Nested Loop
542
- const joinRows = this._scanTable(joinEntry, null);
543
- for (const leftRow of currentRows) {
544
- for (const rightRow of joinRows) {
545
- const rightRowPrefixed = { ...rightRow };
546
- for (const k in rightRow) {
547
- rightRowPrefixed[`${join.table}.${k}`] = rightRow[k];
548
- }
549
- const lVal = leftRow[join.on.left];
550
- const rVal = rightRowPrefixed[join.on.right];
551
- let match = false;
552
-
553
- // Loose equality for cross-type (string vs number)
554
- if (join.on.op === '=') match = lVal == rVal;
555
- // Add other ops if needed
556
-
557
- if (match) {
558
- nextRows.push({ ...leftRow, ...rightRowPrefixed });
559
- }
560
- }
561
- }
562
- }
563
- currentRows = nextRows;
564
- }
565
- results = currentRows;
196
+ // Wrapper for index update/removal if needed by old code (though logic moved to indexManager)
197
+ _updateIndexes(t, n, o) { this.indexManager.updateIndexes(t, n, o); }
198
+ _removeFromIndexes(t, d) { this.indexManager.removeFromIndexes(t, d); }
199
+ _insert(t, d) { return this.insertExecutor.insertMany(t, [d]); }
200
+ _updateTableLastPage(t, p) { this.tableManager.updateTableLastPage(t, p); }
201
+ _delete(t, c, f) { return this.deleteExecutor.delete(t, c, f); }
566
202
 
567
- if (criteria) {
568
- results = results.filter(r => this._checkMatch(r, criteria));
569
- }
570
-
571
- } else {
572
- // OPTIMIZATION: Use index for = queries
573
- if (criteria && criteria.op === '=' && !sort) {
574
- const indexKey = `${table}.${criteria.key}`;
575
- if (this.indexes.has(indexKey)) {
576
- const index = this.indexes.get(indexKey);
577
- results = index.search(criteria.val);
578
- } else {
579
- // If sorting, we cannot limit the scan early
580
- const scanLimit = sort ? null : limit;
581
- results = this._scanTable(entry, criteria, scanLimit);
582
- }
583
- } else {
584
- // If sorting, we cannot limit the scan early
585
- const scanLimit = sort ? null : limit;
586
- results = this._scanTable(entry, criteria, scanLimit);
587
- }
588
- }
589
-
590
- // Sorting
591
- if (sort) {
592
- results.sort((a, b) => {
593
- const valA = a[sort.key];
594
- const valB = b[sort.key];
595
- if (valA < valB) return sort.dir === 'asc' ? -1 : 1;
596
- if (valA > valB) return sort.dir === 'asc' ? 1 : -1;
597
- return 0;
598
- });
599
- }
600
-
601
- // Limit & Offset
602
- let start = 0;
603
- let end = results.length;
604
-
605
- if (offsetCount) start = offsetCount;
606
- if (limit) end = start + limit;
607
- if (end > results.length) end = results.length;
608
- if (start > results.length) start = results.length;
609
-
610
- return results.slice(start, end);
611
- }
612
-
613
- // Modifiy _scanTable to allow returning extended info (pageId) for internal use
614
203
  // Modifiy _scanTable to allow returning extended info (pageId) for internal use
615
204
  _scanTable(entry, criteria, limit = null, returnRaw = false) {
616
205
  let currentPageId = entry.startPage;
@@ -624,7 +213,6 @@ class SawitDB {
624
213
  const criteriaVal = hasSimpleCriteria ? criteria.val : null;
625
214
 
626
215
  while (currentPageId !== 0 && results.length < effectiveLimit) {
627
- // NEW: Use Object Cache
628
216
  // Returns { next: uint32, items: Array<Object> }
629
217
  const pageData = this.pager.readPageObjects(currentPageId);
630
218
 
@@ -635,6 +223,7 @@ class SawitDB {
635
223
  let matches = true;
636
224
  if (hasSimpleCriteria) {
637
225
  const val = obj[criteriaKey];
226
+ // Inline simple checks for speed
638
227
  switch (criteriaOp) {
639
228
  case '=': matches = (val == criteriaVal); break;
640
229
  case '>': matches = (val > criteriaVal); break;
@@ -646,16 +235,16 @@ class SawitDB {
646
235
  const pattern = criteriaVal.replace(/%/g, '.*').replace(/_/g, '.');
647
236
  matches = new RegExp('^' + pattern + '$', 'i').test(val);
648
237
  break;
649
- default: matches = this._checkMatch(obj, criteria);
238
+ default:
239
+ matches = this.conditionEvaluator.checkSingleCondition(obj, criteria);
650
240
  }
651
241
  } else if (criteria) {
652
- matches = this._checkMatch(obj, criteria);
242
+ matches = this.conditionEvaluator.checkMatch(obj, criteria);
653
243
  }
654
244
 
655
245
  if (matches) {
656
246
  if (returnRaw) {
657
247
  // Inject Page Hint
658
- // Safe to modify cached object (it's non-enumerable)
659
248
  Object.defineProperty(obj, '_pageId', {
660
249
  value: currentPageId,
661
250
  enumerable: false, // Hidden
@@ -673,356 +262,188 @@ class SawitDB {
673
262
  return results;
674
263
  }
675
264
 
676
- _loadIndexes() {
677
- // Re-implement load indexes to include Hints
678
- const indexRecords = this._select('_indexes', null);
679
- for (const rec of indexRecords) {
680
- const table = rec.table;
681
- const field = rec.field;
682
- const indexKey = `${table}.${field}`;
683
-
684
- if (!this.indexes.has(indexKey)) {
685
- const index = new BTreeIndex();
686
- index.name = indexKey;
687
- index.keyField = field;
688
-
689
- try {
690
- // Fetch all records with Hints
691
- const entry = this._findTableEntry(table);
692
- if (entry) {
693
- const allRecords = this._scanTable(entry, null, null, true); // true for Hints
694
- for (const record of allRecords) {
695
- if (record.hasOwnProperty(field)) {
696
- index.insert(record[field], record);
697
- }
698
- }
699
- this.indexes.set(indexKey, index);
700
- }
701
- } catch (e) {
702
- console.error(`Failed to rebuild index ${indexKey}: ${e.message}`);
265
+ /**
266
+ * EXPLAIN - Analyze query execution plan
267
+ * Returns information about how the query would be executed
268
+ */
269
+ _explain(cmd) {
270
+ const plan = {
271
+ type: cmd.type,
272
+ table: cmd.table,
273
+ steps: []
274
+ };
275
+
276
+ switch (cmd.type) {
277
+ case 'SELECT': {
278
+ const entry = this.tableManager.findTableEntry(cmd.table);
279
+ if (!entry) {
280
+ plan.error = `Table '${cmd.table}' not found`;
281
+ return plan;
703
282
  }
704
- }
705
- }
706
- }
707
283
 
708
- _delete(table, criteria) {
709
- const entry = this._findTableEntry(table);
710
- if (!entry) throw new Error(`Kebun '${table}' tidak ditemukan.`);
711
-
712
- // OPTIMIZATION: Check Index Hint for simple equality delete
713
- let hintPageId = -1;
714
- if (criteria && criteria.op === '=' && criteria.key) {
715
- const indexKey = `${table}.${criteria.key}`;
716
- if (this.indexes.has(indexKey)) {
717
- const index = this.indexes.get(indexKey);
718
- const searchRes = index.search(criteria.val);
719
- if (searchRes.length > 0 && searchRes[0]._pageId !== undefined) {
720
- // Use the hint! Scan ONLY this page
721
- hintPageId = searchRes[0]._pageId;
722
- // Note: If multiple results, we might need to check multiple pages.
723
- // For now simple single record optimization.
724
- }
725
- }
726
- }
727
-
728
- let currentPageId = (hintPageId !== -1) ? hintPageId : entry.startPage;
729
- let deletedCount = 0;
730
-
731
- // Loop: If hint used, only loop once (unless next page logic needed, but pageId is specific)
732
- // We modify the while condition
733
-
734
- while (currentPageId !== 0) {
735
- let pData = this.pager.readPage(currentPageId);
736
- const count = pData.readUInt16LE(4);
737
- let offset = 8;
738
- const recordsToKeep = [];
739
- let pageModified = false;
740
-
741
- for (let i = 0; i < count; i++) {
742
- const len = pData.readUInt16LE(offset);
743
- const jsonStr = pData.toString('utf8', offset + 2, offset + 2 + len);
744
- let shouldDelete = false;
745
- try {
746
- const obj = JSON.parse(jsonStr);
747
- if (this._checkMatch(obj, criteria)) shouldDelete = true;
748
- } catch (e) { }
284
+ // Check if joins are used
285
+ if (cmd.joins && cmd.joins.length > 0) {
286
+ plan.steps.push({
287
+ operation: 'SCAN',
288
+ table: cmd.table,
289
+ method: 'Full Table Scan',
290
+ reason: 'Base table for JOIN'
291
+ });
749
292
 
750
- if (shouldDelete) {
751
- deletedCount++;
752
- // Remove from Index if needed
753
- if (table !== '_indexes') {
754
- this._removeFromIndexes(table, JSON.parse(jsonStr));
293
+ for (const join of cmd.joins) {
294
+ const joinType = join.type || 'INNER';
295
+ const useHashJoin = join.on && join.on.op === '=';
296
+ plan.steps.push({
297
+ operation: `${joinType} JOIN`,
298
+ table: join.table,
299
+ method: useHashJoin ? 'Hash Join' : 'Nested Loop Join',
300
+ condition: join.on ? `${join.on.left} ${join.on.op} ${join.on.right}` : 'CROSS'
301
+ });
302
+ }
303
+ } else if (cmd.criteria) {
304
+ // Check index usage
305
+ const indexKey = `${cmd.table}.${cmd.criteria.key}`;
306
+ const hasIndex = this.indexes.has(indexKey);
307
+
308
+ if (hasIndex && cmd.criteria.op === '=') {
309
+ plan.steps.push({
310
+ operation: 'INDEX SCAN',
311
+ table: cmd.table,
312
+ index: indexKey,
313
+ method: 'B-Tree Index Lookup',
314
+ condition: `${cmd.criteria.key} ${cmd.criteria.op} ${JSON.stringify(cmd.criteria.val)}`
315
+ });
316
+ } else {
317
+ plan.steps.push({
318
+ operation: 'TABLE SCAN',
319
+ table: cmd.table,
320
+ method: hasIndex ? 'Full Scan (index not usable for this operator)' : 'Full Table Scan',
321
+ condition: `${cmd.criteria.key} ${cmd.criteria.op} ${JSON.stringify(cmd.criteria.val)}`
322
+ });
755
323
  }
756
- pageModified = true;
757
324
  } else {
758
- recordsToKeep.push({ len, data: pData.slice(offset + 2, offset + 2 + len) });
325
+ plan.steps.push({
326
+ operation: 'TABLE SCAN',
327
+ table: cmd.table,
328
+ method: 'Full Table Scan',
329
+ reason: 'No WHERE clause'
330
+ });
759
331
  }
760
- offset += 2 + len;
761
- }
762
-
763
- if (pageModified) {
764
- let writeOffset = 8;
765
- pData.writeUInt16LE(recordsToKeep.length, 4);
766
332
 
767
- for (let rec of recordsToKeep) {
768
- pData.writeUInt16LE(rec.len, writeOffset);
769
- rec.data.copy(pData, writeOffset + 2);
770
- writeOffset += 2 + rec.len;
333
+ // DISTINCT step
334
+ if (cmd.distinct) {
335
+ plan.steps.push({
336
+ operation: 'DISTINCT',
337
+ method: 'Hash-based deduplication'
338
+ });
771
339
  }
772
- pData.writeUInt16LE(writeOffset, 6); // New free offset
773
- pData.fill(0, writeOffset); // Zero out rest
774
-
775
- this.pager.writePage(currentPageId, pData);
776
- }
777
-
778
- // Next page logic
779
- if (hintPageId !== -1) {
780
- break; // Optimized single page scan done
781
- }
782
- currentPageId = pData.readUInt32LE(0);
783
- }
784
-
785
- // If hint failed (record moved?), fallback to full scan?
786
- // For now assume hint is good. If record moved, it's effectively deleted from old page already (during move).
787
- // If we missed it, it means inconsistency. But with this engine, move only happens on Update overflow.
788
340
 
789
- if (hintPageId !== -1 && deletedCount === 0) {
790
- // Hint failed (maybe race condition or stale index?), fallback to full scan
791
- // This ensures safety.
792
- return this._deleteFullScan(entry, criteria);
793
- }
794
-
795
- return `Berhasil menggusur ${deletedCount} bibit.`;
796
- }
797
-
798
- _deleteFullScan(entry, criteria) {
799
- let currentPageId = entry.startPage;
800
- let deletedCount = 0;
801
- while (currentPageId !== 0) {
802
- // ... (Duplicate logic or refactor? For brevity, I'll rely on the main loop above if I set hintPageId = -1)
803
- // But since function is big, let's keep it simple.
804
- // If fallback needed, recursive call:
805
- return this._delete(entry.name, criteria); // But wait, entry.name not passed.
806
- // Refactor _delete to take (table, criteria, forceFullScan=false)
807
- }
808
- return `Fallback deleted ${deletedCount}`;
809
- }
810
-
811
- _removeFromIndexes(table, data) {
812
- for (const [indexKey, index] of this.indexes) {
813
- const [tbl, field] = indexKey.split('.');
814
- if (tbl === table && data.hasOwnProperty(field)) {
815
- index.delete(data[field]); // Basic deletion from B-Tree
816
- }
817
- }
818
- }
819
-
820
- _update(table, updates, criteria) {
821
- const entry = this._findTableEntry(table);
822
- if (!entry) throw new Error(`Kebun '${table}' tidak ditemukan.`);
823
-
824
- // OPTIMIZATION: Check Index Hint for simple equality update
825
- let hintPageId = -1;
826
- if (criteria && criteria.op === '=' && criteria.key) {
827
- const indexKey = `${table}.${criteria.key}`;
828
- if (this.indexes.has(indexKey)) {
829
- const index = this.indexes.get(indexKey);
830
- const searchRes = index.search(criteria.val);
831
- if (searchRes.length > 0 && searchRes[0]._pageId !== undefined) {
832
- hintPageId = searchRes[0]._pageId;
341
+ // Sorting step
342
+ if (cmd.sort) {
343
+ plan.steps.push({
344
+ operation: 'SORT',
345
+ field: cmd.sort.by,
346
+ direction: cmd.sort.order || 'ASC'
347
+ });
833
348
  }
834
- }
835
- }
836
-
837
- let currentPageId = (hintPageId !== -1) ? hintPageId : entry.startPage;
838
- let updatedCount = 0;
839
-
840
- // OPTIMIZATION: In-place update instead of DELETE+INSERT
841
- while (currentPageId !== 0) {
842
- let pData = this.pager.readPage(currentPageId);
843
- const count = pData.readUInt16LE(4);
844
- let offset = 8;
845
- let modified = false;
846
-
847
- for (let i = 0; i < count; i++) {
848
- const len = pData.readUInt16LE(offset);
849
- const jsonStr = pData.toString('utf8', offset + 2, offset + 2 + len);
850
-
851
- try {
852
- const obj = JSON.parse(jsonStr);
853
-
854
- if (this._checkMatch(obj, criteria)) {
855
- // Apply updates
856
-
857
- for (const k in updates) {
858
- obj[k] = updates[k];
859
- }
860
-
861
- // Update index if needed
862
- // FIX: Inject _pageId hint so the index knows where this record lives
863
- Object.defineProperty(obj, '_pageId', {
864
- value: currentPageId,
865
- enumerable: false,
866
- writable: true
867
- });
868
-
869
- this._updateIndexes(table, JSON.parse(jsonStr), obj);
870
-
871
- // Serialize updated object
872
- const newJsonStr = JSON.stringify(obj);
873
- const newLen = Buffer.byteLength(newJsonStr, 'utf8');
874
-
875
- // Check if it fits in same space
876
- if (newLen <= len) {
877
- // In-place update
878
- pData.writeUInt16LE(newLen, offset);
879
- pData.write(newJsonStr, offset + 2, newLen, 'utf8');
880
- // Zero out remaining space
881
- if (newLen < len) {
882
- pData.fill(0, offset + 2 + newLen, offset + 2 + len);
883
- }
884
- modified = true;
885
- updatedCount++;
886
- } else {
887
- // Fallback: DELETE + INSERT (rare case)
888
- this._delete(table, criteria);
889
- this._insert(table, obj);
890
- updatedCount++;
891
- break; // Exit loop as page structure changed
892
- }
893
- }
894
- } catch (err) { }
895
349
 
896
- offset += 2 + len;
897
- }
350
+ // Limit/Offset step
351
+ if (cmd.limit || cmd.offset) {
352
+ plan.steps.push({
353
+ operation: 'LIMIT/OFFSET',
354
+ limit: cmd.limit || 'none',
355
+ offset: cmd.offset || 0
356
+ });
357
+ }
898
358
 
899
- if (modified) {
900
- this.pager.writePage(currentPageId, pData);
359
+ // Projection step
360
+ if (cmd.cols && !(cmd.cols.length === 1 && cmd.cols[0] === '*')) {
361
+ plan.steps.push({
362
+ operation: 'PROJECT',
363
+ columns: cmd.cols
364
+ });
365
+ }
366
+ break;
901
367
  }
902
368
 
903
- if (hintPageId !== -1) break; // Scan only one page
904
-
905
- currentPageId = pData.readUInt32LE(0);
906
- }
907
-
908
- if (hintPageId !== -1 && updatedCount === 0) {
909
- // Hint failed, fallback (not implemented fully for update, assume safe)
910
- // But to be safe, restart scan? For now let's hope hint works.
911
- // TODO: Fallback to full scan logic if mission critical.
912
- }
913
-
914
- return `Berhasil memupuk ${updatedCount} bibit.`;
915
- }
916
-
917
- _createIndex(table, field) {
918
- const entry = this._findTableEntry(table);
919
- if (!entry) throw new Error(`Kebun '${table}' tidak ditemukan.`);
369
+ case 'DELETE':
370
+ case 'UPDATE': {
371
+ const entry = this.tableManager.findTableEntry(cmd.table);
372
+ if (!entry) {
373
+ plan.error = `Table '${cmd.table}' not found`;
374
+ return plan;
375
+ }
920
376
 
921
- const indexKey = `${table}.${field}`;
922
- if (this.indexes.has(indexKey)) {
923
- return `Indeks pada '${table}.${field}' sudah ada.`;
924
- }
377
+ if (cmd.criteria) {
378
+ const indexKey = `${cmd.table}.${cmd.criteria.key}`;
379
+ const hasIndex = this.indexes.has(indexKey);
925
380
 
926
- // Create index
927
- const index = new BTreeIndex();
928
- index.name = indexKey;
929
- index.keyField = field;
381
+ plan.steps.push({
382
+ operation: 'SCAN',
383
+ table: cmd.table,
384
+ method: hasIndex && cmd.criteria.op === '=' ? 'Index-assisted scan' : 'Full Table Scan',
385
+ condition: `${cmd.criteria.key} ${cmd.criteria.op} ${JSON.stringify(cmd.criteria.val)}`
386
+ });
387
+ } else {
388
+ plan.steps.push({
389
+ operation: 'SCAN',
390
+ table: cmd.table,
391
+ method: 'Full Table Scan',
392
+ reason: 'No WHERE clause - affects all rows'
393
+ });
394
+ }
930
395
 
931
- // Build index from existing data
932
- const allRecords = this._select(table, null);
933
- for (const record of allRecords) {
934
- if (record.hasOwnProperty(field)) {
935
- index.insert(record[field], record);
396
+ plan.steps.push({
397
+ operation: cmd.type,
398
+ table: cmd.table,
399
+ method: 'In-place modification'
400
+ });
401
+ break;
936
402
  }
937
- }
938
403
 
939
- this.indexes.set(indexKey, index);
940
-
941
- // PERSISTENCE: Save to _indexes table
942
- try {
943
- this._insert('_indexes', { table, field });
944
- } catch (e) {
945
- console.error("Failed to persist index definition", e);
946
- }
404
+ case 'AGGREGATE': {
405
+ plan.steps.push({
406
+ operation: 'SCAN',
407
+ table: cmd.table,
408
+ method: cmd.criteria ? 'Filtered Scan' : 'Full Table Scan'
409
+ });
410
+
411
+ if (cmd.groupBy) {
412
+ plan.steps.push({
413
+ operation: 'GROUP',
414
+ field: cmd.groupBy,
415
+ method: 'Hash-based grouping'
416
+ });
417
+ }
947
418
 
948
- return `Indeks dibuat pada '${table}.${field}' (${allRecords.length} records indexed)`;
949
- }
419
+ plan.steps.push({
420
+ operation: 'AGGREGATE',
421
+ function: cmd.func,
422
+ field: cmd.field || '*'
423
+ });
950
424
 
951
- _showIndexes(table) {
952
- if (table) {
953
- const indexes = [];
954
- for (const [key, index] of this.indexes) {
955
- if (key.startsWith(table + '.')) {
956
- indexes.push(index.stats());
425
+ if (cmd.having) {
426
+ plan.steps.push({
427
+ operation: 'HAVING',
428
+ condition: `${cmd.having.field} ${cmd.having.op} ${cmd.having.val}`
429
+ });
957
430
  }
431
+ break;
958
432
  }
959
- return indexes.length > 0 ? indexes : `Tidak ada indeks pada '${table}'`;
960
- } else {
961
- const allIndexes = [];
962
- for (const index of this.indexes.values()) {
963
- allIndexes.push(index.stats());
964
- }
965
- return allIndexes;
966
- }
967
- }
968
-
969
- _aggregate(table, func, field, criteria, groupBy) {
970
- const records = this._select(table, criteria);
971
-
972
- if (groupBy) {
973
- return this._groupedAggregate(records, func, field, groupBy);
974
433
  }
975
434
 
976
- switch (func.toUpperCase()) {
977
- case 'COUNT':
978
- return { count: records.length };
979
-
980
- case 'SUM':
981
- if (!field) throw new Error("SUM requires a field");
982
- const sum = records.reduce((acc, r) => acc + (Number(r[field]) || 0), 0);
983
- return { sum, field };
984
-
985
- case 'AVG':
986
- if (!field) throw new Error("AVG requires a field");
987
- const avg = records.reduce((acc, r) => acc + (Number(r[field]) || 0), 0) / records.length;
988
- return { avg, field, count: records.length };
989
-
990
- case 'MIN':
991
- if (!field) throw new Error("MIN requires a field");
992
- const min = Math.min(...records.map(r => Number(r[field]) || Infinity));
993
- return { min, field };
994
-
995
- case 'MAX':
996
- if (!field) throw new Error("MAX requires a field");
997
- const max = Math.max(...records.map(r => Number(r[field]) || -Infinity));
998
- return { max, field };
999
-
1000
- default:
1001
- throw new Error(`Unknown aggregate function: ${func}`);
435
+ // Add available indexes info
436
+ const tableIndexes = [];
437
+ for (const [key] of this.indexes) {
438
+ if (key.startsWith(cmd.table + '.')) {
439
+ tableIndexes.push(key);
440
+ }
1002
441
  }
1003
- }
1004
-
1005
- _groupedAggregate(records, func, field, groupBy) {
1006
- const groups = {};
1007
- for (const record of records) {
1008
- const key = record[groupBy];
1009
- if (!groups[key]) groups[key] = [];
1010
- groups[key].push(record);
442
+ if (tableIndexes.length > 0) {
443
+ plan.availableIndexes = tableIndexes;
1011
444
  }
1012
445
 
1013
- const results = [];
1014
- for (const [key, groupRecords] of Object.entries(groups)) {
1015
- const result = { [groupBy]: key };
1016
- switch (func.toUpperCase()) {
1017
- case 'COUNT': result.count = groupRecords.length; break;
1018
- case 'SUM': result.sum = groupRecords.reduce((acc, r) => acc + (Number(r[field]) || 0), 0); break;
1019
- case 'AVG': result.avg = groupRecords.reduce((acc, r) => acc + (Number(r[field]) || 0), 0) / groupRecords.length; break;
1020
- case 'MIN': result.min = Math.min(...groupRecords.map(r => Number(r[field]) || Infinity)); break;
1021
- case 'MAX': result.max = Math.max(...groupRecords.map(r => Number(r[field]) || -Infinity)); break;
1022
- }
1023
- results.push(result);
1024
- }
1025
- return results;
446
+ return plan;
1026
447
  }
1027
448
  }
1028
449