@wowoengine/sawitdb 2.4.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.
@@ -0,0 +1,569 @@
1
+
2
+ /**
3
+ * QueryParser handles tokenizing and parsing SQL-like commands
4
+ * Returns a Command Object: { type, table, data, criteria, ... }
5
+ */
6
+ class QueryParser {
7
+ constructor() { }
8
+
9
+ tokenize(sql) {
10
+ // Regex to match tokens
11
+ const tokenRegex = /\s*(=>|!=|>=|<=|<>|[(),=*.<>?]|[a-zA-Z_]\w*|@\w+|\d+|'[^']*'|"[^"]*")\s*/g;
12
+ const tokens = [];
13
+ let match;
14
+ while ((match = tokenRegex.exec(sql)) !== null) {
15
+ tokens.push(match[1]);
16
+ }
17
+ return tokens;
18
+ }
19
+
20
+ parse(queryString, params) {
21
+ const tokens = this.tokenize(queryString);
22
+ if (tokens.length === 0) return { type: 'EMPTY' };
23
+
24
+ const cmd = tokens[0].toUpperCase();
25
+ let command;
26
+
27
+ try {
28
+ switch (cmd) {
29
+ case 'LAHAN':
30
+ case 'CREATE':
31
+ if (tokens[1] && tokens[1].toUpperCase() === 'INDEX') {
32
+ command = this.parseCreateIndex(tokens);
33
+ } else {
34
+ command = this.parseCreate(tokens);
35
+ }
36
+ break;
37
+ case 'LIHAT':
38
+ case 'SHOW':
39
+ command = this.parseShow(tokens);
40
+ break;
41
+ case 'TANAM':
42
+ case 'INSERT':
43
+ command = this.parseInsert(tokens);
44
+ break;
45
+ case 'PANEN':
46
+ case 'SELECT':
47
+ command = this.parseSelect(tokens);
48
+ break;
49
+ case 'GUSUR':
50
+ case 'DELETE':
51
+ command = this.parseDelete(tokens);
52
+ break;
53
+ case 'PUPUK':
54
+ case 'UPDATE':
55
+ command = this.parseUpdate(tokens);
56
+ break;
57
+ case 'BAKAR':
58
+ case 'DROP':
59
+ command = this.parseDrop(tokens);
60
+ break;
61
+ case 'INDEKS':
62
+ command = this.parseCreateIndex(tokens);
63
+ break;
64
+ case 'HITUNG':
65
+ command = this.parseAggregate(tokens);
66
+ break;
67
+ default:
68
+ throw new Error(`Perintah tidak dikenal: ${cmd}`);
69
+ }
70
+
71
+ if (params) {
72
+ this._bindParameters(command, params);
73
+ }
74
+ return command;
75
+ } catch (e) {
76
+ return { type: 'ERROR', message: e.message };
77
+ }
78
+ }
79
+
80
+ // --- Parser Methods ---
81
+
82
+ parseCreate(tokens) {
83
+ let name;
84
+ if (tokens[0].toUpperCase() === 'CREATE') {
85
+ if (tokens[1].toUpperCase() !== 'TABLE') throw new Error("Syntax: CREATE TABLE [name]");
86
+ name = tokens[2];
87
+ } else {
88
+ if (tokens.length < 2) throw new Error("Syntax: LAHAN [nama_kebun]");
89
+ name = tokens[1];
90
+ }
91
+ return { type: 'CREATE_TABLE', table: name };
92
+ }
93
+
94
+ parseShow(tokens) {
95
+ const cmd = tokens[0].toUpperCase();
96
+ const sub = tokens[1] ? tokens[1].toUpperCase() : '';
97
+
98
+ if (cmd === 'LIHAT') {
99
+ if (sub === 'LAHAN') return { type: 'SHOW_TABLES' };
100
+ if (sub === 'INDEKS') return { type: 'SHOW_INDEXES', table: tokens[2] || null };
101
+ } else if (cmd === 'SHOW') {
102
+ if (sub === 'TABLES') return { type: 'SHOW_TABLES' };
103
+ if (sub === 'INDEXES') return { type: 'SHOW_INDEXES', table: tokens[2] || null };
104
+ }
105
+
106
+ throw new Error("Syntax: LIHAT LAHAN | SHOW TABLES | LIHAT INDEKS [table] | SHOW INDEXES");
107
+ }
108
+
109
+ parseDrop(tokens) {
110
+ if (tokens[0].toUpperCase() === 'DROP') {
111
+ if (tokens[1] && tokens[1].toUpperCase() === 'TABLE') {
112
+ return { type: 'DROP_TABLE', table: tokens[2] };
113
+ }
114
+ } else if (tokens[0].toUpperCase() === 'BAKAR') {
115
+ if (tokens[1] && tokens[1].toUpperCase() === 'LAHAN') {
116
+ return { type: 'DROP_TABLE', table: tokens[2] };
117
+ }
118
+ }
119
+ throw new Error("Syntax: BAKAR LAHAN [nama] | DROP TABLE [nama]");
120
+ }
121
+
122
+ parseInsert(tokens) {
123
+ let i = 1;
124
+ let table;
125
+
126
+ if (tokens[0].toUpperCase() === 'INSERT') {
127
+ if (tokens[1].toUpperCase() !== 'INTO') throw new Error("Syntax: INSERT INTO [table] ...");
128
+ i = 2;
129
+ } else {
130
+ if (tokens[1].toUpperCase() !== 'KE') throw new Error("Syntax: TANAM KE [kebun] ...");
131
+ i = 2;
132
+ }
133
+
134
+ table = tokens[i];
135
+ i++;
136
+
137
+ const cols = [];
138
+ if (tokens[i] === '(') {
139
+ i++;
140
+ while (tokens[i] !== ')') {
141
+ if (tokens[i] !== ',') cols.push(tokens[i]);
142
+ i++;
143
+ if (i >= tokens.length) throw new Error("Unclosed parenthesis in columns");
144
+ }
145
+ i++;
146
+ } else {
147
+ throw new Error("Syntax: ... [table] (col1, ...) ...");
148
+ }
149
+
150
+ const valueKeyword = tokens[i].toUpperCase();
151
+ if (valueKeyword !== 'BIBIT' && valueKeyword !== 'VALUES') throw new Error("Expected BIBIT or VALUES");
152
+ i++;
153
+
154
+ const vals = [];
155
+ if (tokens[i] === '(') {
156
+ i++;
157
+ while (tokens[i] !== ')') {
158
+ if (tokens[i] !== ',') {
159
+ let val = tokens[i];
160
+ if (val.startsWith("'") || val.startsWith('"')) val = val.slice(1, -1);
161
+ else if (val.toUpperCase() === 'NULL') val = null;
162
+ else if (val.toUpperCase() === 'TRUE') val = true;
163
+ else if (val.toUpperCase() === 'FALSE') val = false;
164
+ else if (!isNaN(val)) val = Number(val);
165
+ vals.push(val);
166
+ }
167
+ i++;
168
+ }
169
+ } else {
170
+ throw new Error("Syntax: ... VALUES (val1, ...)");
171
+ }
172
+
173
+ if (cols.length !== vals.length) throw new Error("Columns and Values count mismatch");
174
+
175
+ const data = {};
176
+ for (let k = 0; k < cols.length; k++) {
177
+ data[cols[k]] = vals[k];
178
+ }
179
+
180
+ return { type: 'INSERT', table, data };
181
+ }
182
+
183
+ parseSelect(tokens) {
184
+ let i = 1;
185
+ const cols = [];
186
+ while (i < tokens.length && !['DARI', 'FROM'].includes(tokens[i].toUpperCase())) {
187
+ if (tokens[i] !== ',') cols.push(tokens[i]);
188
+ i++;
189
+ }
190
+
191
+ if (i >= tokens.length) throw new Error("Expected DARI or FROM");
192
+ i++;
193
+
194
+ const table = tokens[i];
195
+ i++;
196
+
197
+ let criteria = null;
198
+ if (i < tokens.length && ['DIMANA', 'WHERE'].includes(tokens[i].toUpperCase())) {
199
+ i++;
200
+ // Calculate whereEndIndex by checking for ORDER or LIMIT or END
201
+ criteria = this.parseWhere(tokens, i);
202
+ // Move i past the WHERE clause
203
+ // parseWhere logic assumes it stops at keywords, but we need to sync `i` in this parent method.
204
+ // Since parseWhere doesn't return new index, we must scan forward or refactor parseWhere.
205
+ // Simplified: scan until keyword.
206
+ while (i < tokens.length && !['ORDER', 'LIMIT', 'OFFSET'].includes(tokens[i].toUpperCase())) {
207
+ i++;
208
+ }
209
+ }
210
+
211
+ let sort = null;
212
+ if (i < tokens.length && tokens[i].toUpperCase() === 'ORDER') {
213
+ i++; // ORDER
214
+ if (tokens[i].toUpperCase() === 'BY') i++;
215
+ const key = tokens[i];
216
+ i++;
217
+ let dir = 'asc';
218
+ if (i < tokens.length && ['ASC', 'DESC'].includes(tokens[i].toUpperCase())) {
219
+ dir = tokens[i].toLowerCase();
220
+ i++;
221
+ }
222
+ sort = { key, dir };
223
+ }
224
+
225
+ let limit = null;
226
+ let offset = null;
227
+
228
+ if (i < tokens.length && tokens[i].toUpperCase() === 'LIMIT') {
229
+ i++;
230
+ limit = parseInt(tokens[i]);
231
+ i++;
232
+ }
233
+
234
+ if (i < tokens.length && tokens[i].toUpperCase() === 'OFFSET') {
235
+ i++;
236
+ offset = parseInt(tokens[i]);
237
+ i++;
238
+ }
239
+
240
+ return { type: 'SELECT', table, cols, criteria, sort, limit, offset };
241
+ }
242
+
243
+ parseWhere(tokens, startIndex) {
244
+ const conditions = [];
245
+ let i = startIndex;
246
+ let currentLogic = 'AND';
247
+
248
+ while (i < tokens.length) {
249
+ const token = tokens[i];
250
+ const upper = token ? token.toUpperCase() : '';
251
+
252
+ if (upper === 'AND' || upper === 'OR') {
253
+ currentLogic = upper;
254
+ i++;
255
+ continue;
256
+ }
257
+
258
+ if (['DENGAN', 'ORDER', 'LIMIT', 'OFFSET', 'GROUP', 'KELOMPOK'].includes(upper)) {
259
+ break;
260
+ }
261
+
262
+ // Parse condition: key op val
263
+ if (i < tokens.length - 1) {
264
+ const key = tokens[i];
265
+ const op = tokens[i + 1].toUpperCase();
266
+ let val = null;
267
+ let consumed = 2; // Default consumed for key + op
268
+
269
+ if (op === 'BETWEEN') {
270
+ // Syntax: key BETWEEN v1 AND v2
271
+ // tokens[i] = key
272
+ // tokens[i+1] = BETWEEN
273
+ // tokens[i+2] = v1
274
+ // tokens[i+3] = AND
275
+ // tokens[i+4] = v2
276
+
277
+ let v1 = tokens[i + 2];
278
+ let v2 = tokens[i + 4];
279
+
280
+ // Normalize v1
281
+ if (v1 && (v1.startsWith("'") || v1.startsWith('"'))) v1 = v1.slice(1, -1);
282
+ else if (!isNaN(v1)) v1 = Number(v1);
283
+
284
+ // Normalize v2
285
+ if (v2 && (v2.startsWith("'") || v2.startsWith('"'))) v2 = v2.slice(1, -1);
286
+ else if (!isNaN(v2)) v2 = Number(v2);
287
+
288
+ conditions.push({ key, op: 'BETWEEN', val: [v1, v2], logic: currentLogic });
289
+ consumed = 5;
290
+
291
+ // Check if AND was actually present?
292
+ if (tokens[i + 3].toUpperCase() !== 'AND') {
293
+ throw new Error("Syntax: ... BETWEEN val1 AND val2");
294
+ }
295
+ } else if (op === 'IS') {
296
+ // Syntax: key IS NULL or key IS NOT NULL
297
+ // tokens[i+2] could be NULL or NOT
298
+ const next = tokens[i + 2].toUpperCase();
299
+ if (next === 'NULL') {
300
+ conditions.push({ key, op: 'IS NULL', val: null, logic: currentLogic });
301
+ consumed = 3;
302
+ } else if (next === 'NOT') {
303
+ if (tokens[i + 3].toUpperCase() === 'NULL') {
304
+ conditions.push({ key, op: 'IS NOT NULL', val: null, logic: currentLogic });
305
+ consumed = 4;
306
+ } else {
307
+ throw new Error("Syntax: IS NOT NULL");
308
+ }
309
+ } else {
310
+ throw new Error("Syntax: IS NULL or IS NOT NULL");
311
+ }
312
+ } else if (op === 'IN' || op === 'NOT') {
313
+ // Handle IN (...) or NOT IN (...)
314
+ if (op === 'NOT') {
315
+ if (tokens[i + 2].toUpperCase() !== 'IN') break; // invalid
316
+ consumed++; // skip IN
317
+ // Op becomes NOT IN
318
+ }
319
+
320
+ // Expect ( v1, v2 )
321
+ let p = (op === 'NOT') ? i + 3 : i + 2;
322
+ if (tokens[p] === '(') {
323
+ p++;
324
+ const values = [];
325
+ while (tokens[p] !== ')') {
326
+ if (tokens[p] !== ',') {
327
+ let v = tokens[p];
328
+ if (v.startsWith("'") || v.startsWith('"')) v = v.slice(1, -1);
329
+ else if (!isNaN(v)) v = Number(v);
330
+ values.push(v);
331
+ }
332
+ p++;
333
+ if (p >= tokens.length) break;
334
+ }
335
+ val = values;
336
+ consumed = (p - i) + 1; // +1 for closing paren
337
+ }
338
+ // Normalize OP
339
+ const finalOp = (op === 'NOT') ? 'NOT IN' : 'IN';
340
+ conditions.push({ key, op: finalOp, val, logic: currentLogic });
341
+ i += consumed;
342
+ continue;
343
+ } else {
344
+ // Normal Ops (=, LIKE, etc)
345
+ val = tokens[i + 2];
346
+ if (val && (val.startsWith("'") || val.startsWith('"'))) {
347
+ val = val.slice(1, -1);
348
+ } else if (val && !isNaN(val)) {
349
+ val = Number(val);
350
+ }
351
+ conditions.push({ key, op, val, logic: currentLogic });
352
+ consumed = 3;
353
+ }
354
+
355
+ i += consumed;
356
+ } else {
357
+ break;
358
+ }
359
+ }
360
+
361
+ if (conditions.length === 1) return conditions[0];
362
+ return { type: 'compound', conditions };
363
+ }
364
+
365
+ parseDelete(tokens) {
366
+ let table;
367
+ let i;
368
+
369
+ if (tokens[0].toUpperCase() === 'DELETE') {
370
+ if (tokens[1].toUpperCase() !== 'FROM') throw new Error("Syntax: DELETE FROM [table] ...");
371
+ table = tokens[2];
372
+ i = 3;
373
+ } else {
374
+ if (tokens[1].toUpperCase() !== 'DARI') throw new Error("Syntax: GUSUR DARI [kebun] ...");
375
+ table = tokens[2];
376
+ i = 3;
377
+ }
378
+
379
+ let criteria = null;
380
+ if (i < tokens.length && ['DIMANA', 'WHERE'].includes(tokens[i].toUpperCase())) {
381
+ i++;
382
+ criteria = this.parseWhere(tokens, i);
383
+ }
384
+
385
+ return { type: 'DELETE', table, criteria };
386
+ }
387
+
388
+ parseUpdate(tokens) {
389
+ let table;
390
+ let i;
391
+
392
+ if (tokens[0].toUpperCase() === 'UPDATE') {
393
+ table = tokens[1];
394
+ if (tokens[2].toUpperCase() !== 'SET') throw new Error("Expected SET");
395
+ i = 3;
396
+ } else {
397
+ if (tokens.length < 3) throw new Error("Syntax: PUPUK [kebun] DENGAN ...");
398
+ table = tokens[1];
399
+ if (tokens[2].toUpperCase() !== 'DENGAN') throw new Error("Expected DENGAN");
400
+ i = 3;
401
+ }
402
+
403
+ const updates = {};
404
+ while (i < tokens.length && !['DIMANA', 'WHERE'].includes(tokens[i].toUpperCase())) {
405
+ if (tokens[i] === ',') { i++; continue; }
406
+ const key = tokens[i];
407
+ if (tokens[i + 1] !== '=') throw new Error("Syntax: key=value in update list");
408
+ let val = tokens[i + 2];
409
+ if (val.startsWith("'") || val.startsWith('"')) val = val.slice(1, -1);
410
+ else if (!isNaN(val)) val = Number(val);
411
+ updates[key] = val;
412
+ i += 3;
413
+ }
414
+
415
+ let criteria = null;
416
+ if (i < tokens.length && ['DIMANA', 'WHERE'].includes(tokens[i].toUpperCase())) {
417
+ i++;
418
+ criteria = this.parseWhere(tokens, i);
419
+ }
420
+ return { type: 'UPDATE', table, updates, criteria };
421
+ }
422
+
423
+ parseCreateIndex(tokens) {
424
+ // Tani: INDEKS [table] PADA [field]
425
+ // Generic: CREATE INDEX [name] ON [table] ( [field] )
426
+ // OR: CREATE INDEX ON [table] ( [field] )
427
+
428
+ if (tokens[0].toUpperCase() === 'CREATE' && tokens[1].toUpperCase() === 'INDEX') {
429
+ let i = 2;
430
+ // Optional Index Name (skip if present, look for ON)
431
+ // If tokens[i] is 'ON', then no name provided. Use generic.
432
+ // If tokens[i+1] is 'ON', then tokens[i] is name.
433
+
434
+ if (tokens[i].toUpperCase() !== 'ON' && tokens[i + 1] && tokens[i + 1].toUpperCase() === 'ON') {
435
+ i++; // Skip name
436
+ }
437
+
438
+ if (tokens[i].toUpperCase() !== 'ON') throw new Error("Syntax: CREATE INDEX ... ON [table] ...");
439
+ i++;
440
+
441
+ const table = tokens[i];
442
+ i++;
443
+
444
+ if (tokens[i] !== '(') throw new Error("Syntax: ... ON [table] ( [field] )");
445
+ i++;
446
+
447
+ const field = tokens[i];
448
+ i++;
449
+
450
+ if (tokens[i] !== ')') throw new Error("Unclosed parenthesis for index field");
451
+
452
+ return { type: 'CREATE_INDEX', table, field };
453
+ }
454
+
455
+ // Tani Fallback
456
+ if (tokens.length < 4) throw new Error("Syntax: INDEKS [table] PADA [field]");
457
+ const table = tokens[1];
458
+ if (tokens[2].toUpperCase() !== 'PADA') throw new Error("Expected PADA");
459
+ const field = tokens[3];
460
+ return { type: 'CREATE_INDEX', table, field };
461
+ }
462
+
463
+
464
+
465
+
466
+ parseAggregate(tokens) {
467
+ // Syntax: HITUNG FUNC ( field ) DARI [table] ...
468
+ // Tokens: ['HITUNG', 'SUM', '(', 'stock', ')', 'DARI', ...]
469
+ let i = 1;
470
+
471
+ const aggFunc = tokens[i].toUpperCase();
472
+ i++;
473
+
474
+ if (tokens[i] !== '(') throw new Error("Syntax: HITUNG FUNC(field) ...");
475
+ i++;
476
+
477
+ const aggField = tokens[i] === '*' ? null : tokens[i];
478
+ i++;
479
+
480
+ if (tokens[i] !== ')') throw new Error("Expected closing parenthesis");
481
+ i++;
482
+
483
+ if (!tokens[i] || (tokens[i].toUpperCase() !== 'DARI' && tokens[i].toUpperCase() !== 'FROM')) {
484
+ throw new Error("Expected DARI or FROM");
485
+ }
486
+ i++;
487
+
488
+ const table = tokens[i];
489
+ i++;
490
+
491
+ let criteria = null;
492
+ if (i < tokens.length && ['DIMANA', 'WHERE'].includes(tokens[i].toUpperCase())) {
493
+ i++;
494
+ criteria = this.parseWhere(tokens, i);
495
+ // Fast forward past WHERE clause
496
+ while (i < tokens.length && !['KELOMPOK', 'GROUP'].includes(tokens[i].toUpperCase())) {
497
+ i++;
498
+ }
499
+ }
500
+
501
+ let groupField = null;
502
+ if (i < tokens.length && ['KELOMPOK', 'GROUP'].includes(tokens[i].toUpperCase())) {
503
+ // GROUP BY field
504
+ // Syntax: GROUP BY field
505
+ if (tokens[i].toUpperCase() === 'GROUP' && tokens[i + 1].toUpperCase() === 'BY') {
506
+ i += 2;
507
+ } else {
508
+ i++; // KELOMPOK
509
+ }
510
+ groupField = tokens[i];
511
+ }
512
+
513
+ return { type: 'AGGREGATE', table, func: aggFunc, field: aggField, criteria, groupBy: groupField };
514
+ }
515
+ _bindParameters(command, params) {
516
+ if (!command) return;
517
+
518
+ // Helper to bind a value
519
+ const bindValue = (val) => {
520
+ if (typeof val === 'string' && val.startsWith('@')) {
521
+ // Named parameter
522
+ const paramName = val.substring(1); // remove @
523
+ if (params && params.hasOwnProperty(paramName)) {
524
+ return params[paramName];
525
+ } else if (Array.isArray(params)) {
526
+ // Fallback for array if user matched index? Unlikely for named.
527
+ return val;
528
+ }
529
+ }
530
+ return val;
531
+ };
532
+
533
+ // 1. Bind Criteria (SELECT, DELETE, UPDATE, AGGREGATE)
534
+ if (command.criteria) {
535
+ this._info_bindCriteria(command.criteria, bindValue);
536
+ }
537
+
538
+ // 2. Bind Data (INSERT)
539
+ if (command.data) {
540
+ for (const key in command.data) {
541
+ command.data[key] = bindValue(command.data[key]);
542
+ }
543
+ }
544
+
545
+ // 3. Bind Update values (UPDATE)
546
+ if (command.updates) {
547
+ for (const key in command.updates) {
548
+ command.updates[key] = bindValue(command.updates[key]);
549
+ }
550
+ }
551
+ }
552
+
553
+ _info_bindCriteria(criteria, bindFunc) {
554
+ if (criteria.type === 'compound') {
555
+ for (const cond of criteria.conditions) {
556
+ this._info_bindCriteria(cond, bindFunc);
557
+ }
558
+ } else {
559
+ // Single condition
560
+ if (Array.isArray(criteria.val)) {
561
+ criteria.val = criteria.val.map(v => bindFunc(v));
562
+ } else {
563
+ criteria.val = bindFunc(criteria.val);
564
+ }
565
+ }
566
+ }
567
+ }
568
+
569
+ module.exports = QueryParser;