@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.
- package/README.md +72 -16
- package/bin/sawit-server.js +5 -1
- package/cli/benchmark.js +292 -119
- package/cli/local.js +26 -11
- package/cli/remote.js +26 -11
- package/cli/test.js +110 -72
- package/cli/test_security.js +140 -0
- package/docs/DB Event.md +32 -0
- package/docs/index.html +125 -17
- package/package.json +10 -7
- package/src/SawitClient.js +122 -98
- package/src/SawitServer.js +77 -464
- package/src/SawitWorker.js +55 -0
- package/src/WowoEngine.js +245 -824
- package/src/modules/BTreeIndex.js +54 -24
- package/src/modules/ClusterManager.js +78 -0
- package/src/modules/Env.js +33 -0
- package/src/modules/Pager.js +12 -9
- package/src/modules/QueryParser.js +233 -48
- package/src/modules/ThreadManager.js +84 -0
- package/src/modules/ThreadPool.js +154 -0
- package/src/server/DatabaseRegistry.js +92 -0
- package/src/server/auth/AuthManager.js +92 -0
- package/src/server/router/RequestRouter.js +278 -0
- package/src/server/session/ClientSession.js +19 -0
- package/src/services/IndexManager.js +183 -0
- package/src/services/QueryExecutor.js +11 -0
- package/src/services/TableManager.js +162 -0
- package/src/services/event/DBEvent.js +61 -0
- package/src/services/event/DBEventHandler.js +39 -0
- package/src/services/executors/AggregateExecutor.js +153 -0
- package/src/services/executors/DeleteExecutor.js +134 -0
- package/src/services/executors/InsertExecutor.js +113 -0
- package/src/services/executors/SelectExecutor.js +130 -0
- package/src/services/executors/UpdateExecutor.js +156 -0
- package/src/services/logic/ConditionEvaluator.js +75 -0
- 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.
|
|
67
|
+
if (!this.tableManager.findTableEntry('_indexes')) {
|
|
38
68
|
try {
|
|
39
|
-
this.
|
|
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.
|
|
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
|
-
|
|
89
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
124
|
-
|
|
125
|
-
this.queryCache.
|
|
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.
|
|
142
|
+
return this.tableManager.createTable(cmd.table);
|
|
147
143
|
|
|
148
144
|
case 'SHOW_TABLES':
|
|
149
|
-
return this.
|
|
145
|
+
return this.tableManager.showTables();
|
|
150
146
|
|
|
151
147
|
case 'SHOW_INDEXES':
|
|
152
|
-
return this.
|
|
148
|
+
return this.indexManager.showIndexes(cmd.table);
|
|
153
149
|
|
|
154
150
|
case 'INSERT':
|
|
155
|
-
return this.
|
|
151
|
+
return this.insertExecutor.execute(cmd);
|
|
156
152
|
|
|
157
153
|
case 'SELECT':
|
|
158
|
-
|
|
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.
|
|
157
|
+
return this.deleteExecutor.execute(cmd);
|
|
171
158
|
|
|
172
159
|
case 'UPDATE':
|
|
173
|
-
return this.
|
|
160
|
+
return this.updateExecutor.execute(cmd);
|
|
174
161
|
|
|
175
162
|
case 'DROP_TABLE':
|
|
176
|
-
return this.
|
|
163
|
+
return this.tableManager.dropTable(cmd.table);
|
|
177
164
|
|
|
178
165
|
case 'CREATE_INDEX':
|
|
179
|
-
return this.
|
|
166
|
+
return this.indexManager.createIndex(cmd.table, cmd.field);
|
|
180
167
|
|
|
181
168
|
case 'AGGREGATE':
|
|
182
|
-
return this.
|
|
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
|
|
193
|
-
|
|
194
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
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:
|
|
238
|
+
default:
|
|
239
|
+
matches = this.conditionEvaluator.checkSingleCondition(obj, criteria);
|
|
650
240
|
}
|
|
651
241
|
} else if (criteria) {
|
|
652
|
-
matches = this.
|
|
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
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
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
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
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
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
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
|
-
|
|
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
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
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
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
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
|
-
|
|
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
|
-
|
|
900
|
-
|
|
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
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
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
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
}
|
|
377
|
+
if (cmd.criteria) {
|
|
378
|
+
const indexKey = `${cmd.table}.${cmd.criteria.key}`;
|
|
379
|
+
const hasIndex = this.indexes.has(indexKey);
|
|
925
380
|
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
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
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
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
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
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
|
-
|
|
949
|
-
|
|
419
|
+
plan.steps.push({
|
|
420
|
+
operation: 'AGGREGATE',
|
|
421
|
+
function: cmd.func,
|
|
422
|
+
field: cmd.field || '*'
|
|
423
|
+
});
|
|
950
424
|
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
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
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
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
|
-
|
|
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
|
|