jsql-neo 4.3.0 → 4.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.
- package/README.md +64 -0
- package/bin/jsql +138 -0
- package/index.d.ts +351 -0
- package/index.js +18 -0
- package/lib/migrate.js +242 -0
- package/lib/mysql_server.js +51 -1
- package/lib/redis_server.js +448 -0
- package/lib/sql.js +125 -51
- package/lib/web_ui.js +226 -0
- package/package.json +66 -56
- package/test/smoke.js +58 -0
- package/wasm/browser.d.ts +88 -0
package/lib/sql.js
CHANGED
|
@@ -53,7 +53,7 @@ const KEYWORDS = new Set([
|
|
|
53
53
|
'DATABASES', 'DATABASE', 'DESCRIBE', 'DESC', 'ON', 'DUPLICATE',
|
|
54
54
|
'JOIN', 'LEFT', 'RIGHT', 'INNER', 'OUTER', 'CROSS',
|
|
55
55
|
'GROUP', 'HAVING', 'UNION', 'ALL', 'CASE', 'WHEN', 'THEN', 'ELSE', 'END',
|
|
56
|
-
'BETWEEN', 'USING', 'FULL', 'UNSIGNED', 'ZEROFILL', 'TRUNCATE'
|
|
56
|
+
'BETWEEN', 'USING', 'FULL', 'UNSIGNED', 'ZEROFILL', 'TRUNCATE', 'COLLATE', 'CHARACTER'
|
|
57
57
|
]);
|
|
58
58
|
|
|
59
59
|
function tokenize(sql) {
|
|
@@ -85,7 +85,7 @@ function tokenize(sql) {
|
|
|
85
85
|
while (j < n) {
|
|
86
86
|
if (sql[j] === '\\' && j + 1 < n) {
|
|
87
87
|
const esc = sql[j + 1];
|
|
88
|
-
const map = { n: '\n', t: '\t', r: '\r', '0': '\0', "'": "'", '"': '"', '\\': '\\' };
|
|
88
|
+
const map = { n: '\n', t: '\t', r: '\r', '0': '\0', "'": "'", '"': '"', '\\': '\\', b: '\b', Z: '\x1a', a: '\a' };
|
|
89
89
|
str += map[esc] !== undefined ? map[esc] : esc;
|
|
90
90
|
j += 2;
|
|
91
91
|
} else if (sql[j] === quote) {
|
|
@@ -141,6 +141,22 @@ function tokenize(sql) {
|
|
|
141
141
|
continue;
|
|
142
142
|
}
|
|
143
143
|
|
|
144
|
+
if (c === '@') {
|
|
145
|
+
let j = i;
|
|
146
|
+
while (j < n && sql[j] === '@') j++;
|
|
147
|
+
const ats = j - i;
|
|
148
|
+
let k = j;
|
|
149
|
+
while (k < n && /[A-Za-z0-9_$]/.test(sql[k])) k++;
|
|
150
|
+
if (k > j) {
|
|
151
|
+
tokens.push(new SQLToken('sysvar', sql.slice(j, k), i));
|
|
152
|
+
if (ats > 1) i = k;
|
|
153
|
+
else i = k;
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
i = j;
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
|
|
144
160
|
const two = sql.slice(i, i + 2);
|
|
145
161
|
if (two === '<=' || two === '>=' || two === '!=' || two === '<>' || two === '==') {
|
|
146
162
|
tokens.push(new SQLToken('op', two, i));
|
|
@@ -365,6 +381,13 @@ class Parser {
|
|
|
365
381
|
this.next(); break;
|
|
366
382
|
case 'DEFAULT':
|
|
367
383
|
this.next(); def.default = this.parseValue(); break;
|
|
384
|
+
case 'COLLATE':
|
|
385
|
+
this.next(); if (this.peek().type !== 'op' && this.peek().type !== 'eof') this.next(); break;
|
|
386
|
+
case 'CHARACTER':
|
|
387
|
+
this.next();
|
|
388
|
+
if (this.isKeyword('SET')) this.next();
|
|
389
|
+
if (this.peek().type !== 'op' && this.peek().type !== 'eof') this.next();
|
|
390
|
+
break;
|
|
368
391
|
default:
|
|
369
392
|
return def;
|
|
370
393
|
}
|
|
@@ -859,6 +882,7 @@ class Parser {
|
|
|
859
882
|
return { type: 'column', name: t.value };
|
|
860
883
|
}
|
|
861
884
|
if (t.type === 'number' || t.type === 'string') return { type: 'value', value: t.value };
|
|
885
|
+
if (t.type === 'sysvar') return { type: 'sysvar', name: t.value };
|
|
862
886
|
if (t.type === 'keyword' && t.value === 'NULL') return { type: 'value', value: null };
|
|
863
887
|
if (t.type === 'keyword' && t.value === 'CASE') return this.parseCase();
|
|
864
888
|
if (t.type === 'keyword' && this.peek().type === 'op' && this.peek().value === '(') {
|
|
@@ -1024,13 +1048,35 @@ const OPERATORS = {
|
|
|
1024
1048
|
'>=': (a, b) => a >= b
|
|
1025
1049
|
};
|
|
1026
1050
|
|
|
1027
|
-
function resolveOperand(operand, row) {
|
|
1051
|
+
function resolveOperand(operand, row, ctx) {
|
|
1028
1052
|
if (operand === null || operand === undefined) return null;
|
|
1029
|
-
if (typeof operand === 'string') return resolveOperand({ type: 'column', name: operand }, row);
|
|
1053
|
+
if (typeof operand === 'string') return resolveOperand({ type: 'column', name: operand }, row, ctx);
|
|
1030
1054
|
switch (operand.type) {
|
|
1031
1055
|
case 'value':
|
|
1032
1056
|
case 'literal':
|
|
1033
1057
|
return operand.value;
|
|
1058
|
+
case 'sysvar': {
|
|
1059
|
+
const s = (ctx && ctx.session && ctx.session.sysvars) || {};
|
|
1060
|
+
const name = String(operand.name).toLowerCase();
|
|
1061
|
+
if (s[name] !== undefined) return s[name];
|
|
1062
|
+
const DEFAULTS = {
|
|
1063
|
+
'version': '8.0.0-jsql-neo',
|
|
1064
|
+
'version_comment': 'jsql-neo',
|
|
1065
|
+
'version_compile_os': 'linux',
|
|
1066
|
+
'sql_mode': '',
|
|
1067
|
+
'autocommit': 1,
|
|
1068
|
+
'character_set_client': 'utf8mb4',
|
|
1069
|
+
'character_set_connection': 'utf8mb4',
|
|
1070
|
+
'character_set_results': 'utf8mb4',
|
|
1071
|
+
'collation_connection': 'utf8mb4_general_ci',
|
|
1072
|
+
'transaction_isolation': 'REPEATABLE-READ',
|
|
1073
|
+
'max_allowed_packet': 67108864,
|
|
1074
|
+
'wait_timeout': 28800,
|
|
1075
|
+
'lower_case_table_names': 0,
|
|
1076
|
+
'sql_auto_is_null': 0,
|
|
1077
|
+
};
|
|
1078
|
+
return DEFAULTS[name] !== undefined ? DEFAULTS[name] : '';
|
|
1079
|
+
}
|
|
1034
1080
|
case 'column': {
|
|
1035
1081
|
const n = operand.name;
|
|
1036
1082
|
if (n === undefined) return undefined;
|
|
@@ -1043,8 +1089,8 @@ function resolveOperand(operand, row) {
|
|
|
1043
1089
|
return undefined;
|
|
1044
1090
|
}
|
|
1045
1091
|
case 'arith': {
|
|
1046
|
-
const l = resolveOperand(operand.left, row);
|
|
1047
|
-
const r = resolveOperand(operand.right, row);
|
|
1092
|
+
const l = resolveOperand(operand.left, row, ctx);
|
|
1093
|
+
const r = resolveOperand(operand.right, row, ctx);
|
|
1048
1094
|
if (l === null || r === null || l === undefined || r === undefined) return null;
|
|
1049
1095
|
switch (operand.op) {
|
|
1050
1096
|
case '+': return l + r;
|
|
@@ -1056,9 +1102,9 @@ function resolveOperand(operand, row) {
|
|
|
1056
1102
|
return null;
|
|
1057
1103
|
}
|
|
1058
1104
|
case 'func':
|
|
1059
|
-
return applyScalarFunction(operand, row);
|
|
1105
|
+
return applyScalarFunction(operand, row, ctx);
|
|
1060
1106
|
case 'case':
|
|
1061
|
-
return evaluateCaseVal(operand, row);
|
|
1107
|
+
return evaluateCaseVal(operand, row, ctx);
|
|
1062
1108
|
case 'aggregate':
|
|
1063
1109
|
case 'subquery':
|
|
1064
1110
|
return undefined;
|
|
@@ -1067,11 +1113,23 @@ function resolveOperand(operand, row) {
|
|
|
1067
1113
|
}
|
|
1068
1114
|
}
|
|
1069
1115
|
|
|
1070
|
-
function applyScalarFunction(fnNode, row) {
|
|
1116
|
+
function applyScalarFunction(fnNode, row, ctx) {
|
|
1071
1117
|
const name = (fnNode.name || '').toUpperCase();
|
|
1072
|
-
const args = (fnNode.args || []).map(a => resolveOperand(a, row));
|
|
1118
|
+
const args = (fnNode.args || []).map(a => resolveOperand(a, row, ctx));
|
|
1119
|
+
const session = ctx && ctx.session;
|
|
1073
1120
|
switch (name) {
|
|
1074
1121
|
case 'VERSION': return '8.0.0-jsql-neo';
|
|
1122
|
+
case 'LAST_INSERT_ID': {
|
|
1123
|
+
if (args.length > 0) {
|
|
1124
|
+
if (session) session.lastInsertId = args[0];
|
|
1125
|
+
return args[0];
|
|
1126
|
+
}
|
|
1127
|
+
return session && session.lastInsertId !== undefined ? session.lastInsertId : 0;
|
|
1128
|
+
}
|
|
1129
|
+
case 'ROW_COUNT': return session && session.rowCount !== undefined ? session.rowCount : 0;
|
|
1130
|
+
case 'FOUND_ROWS': return session && session.foundRows !== undefined ? session.foundRows : 0;
|
|
1131
|
+
case 'CONNECTION_ID': return session && session.connectionId !== undefined ? session.connectionId : 0;
|
|
1132
|
+
case 'DATABASE': case 'SCHEMA': return session && session.currentDb ? session.currentDb : 'default';
|
|
1075
1133
|
case 'NOW': case 'CURRENT_TIMESTAMP': return new Date().toISOString().slice(0, 19).replace('T', ' ');
|
|
1076
1134
|
case 'CURDATE': case 'CURRENT_DATE': return new Date().toISOString().slice(0, 10);
|
|
1077
1135
|
case 'CURTIME': return new Date().toISOString().slice(11, 19);
|
|
@@ -1156,36 +1214,36 @@ function extractEqualPushdown(expr, schema) {
|
|
|
1156
1214
|
return { filter, rest };
|
|
1157
1215
|
}
|
|
1158
1216
|
|
|
1159
|
-
function evaluateExpr(expr, row) {
|
|
1217
|
+
function evaluateExpr(expr, row, ctx) {
|
|
1160
1218
|
if (expr === null || expr === undefined) return false;
|
|
1161
1219
|
switch (expr.type) {
|
|
1162
|
-
case 'and': return evaluateExpr(expr.left, row) && evaluateExpr(expr.right, row);
|
|
1163
|
-
case 'or': return evaluateExpr(expr.left, row) || evaluateExpr(expr.right, row);
|
|
1164
|
-
case 'not': return !evaluateExpr(expr.expr, row);
|
|
1220
|
+
case 'and': return evaluateExpr(expr.left, row, ctx) && evaluateExpr(expr.right, row, ctx);
|
|
1221
|
+
case 'or': return evaluateExpr(expr.left, row, ctx) || evaluateExpr(expr.right, row, ctx);
|
|
1222
|
+
case 'not': return !evaluateExpr(expr.expr, row, ctx);
|
|
1165
1223
|
case 'compare': {
|
|
1166
|
-
const l = resolveOperand(expr.left, row);
|
|
1167
|
-
const r = resolveOperand(expr.right, row);
|
|
1224
|
+
const l = resolveOperand(expr.left, row, ctx);
|
|
1225
|
+
const r = resolveOperand(expr.right, row, ctx);
|
|
1168
1226
|
if (expr.op === '=') return l === r || (l === null && r === null) || (l !== null && r !== null && String(l) === String(r));
|
|
1169
1227
|
if (l === null || r === null) return false;
|
|
1170
1228
|
const fn = OPERATORS[expr.op];
|
|
1171
1229
|
return typeof l === 'number' && typeof r === 'number' ? fn(l, r) : fn(String(l), String(r));
|
|
1172
1230
|
}
|
|
1173
1231
|
case 'isNull': {
|
|
1174
|
-
const v = resolveOperand(expr.operand, row);
|
|
1232
|
+
const v = resolveOperand(expr.operand, row, ctx);
|
|
1175
1233
|
const isNull = v === null || v === undefined;
|
|
1176
1234
|
return expr.not ? !isNull : isNull;
|
|
1177
1235
|
}
|
|
1178
1236
|
case 'in': {
|
|
1179
|
-
const v = resolveOperand(expr.operand, row);
|
|
1237
|
+
const v = resolveOperand(expr.operand, row, ctx);
|
|
1180
1238
|
if (expr.subquery && expr.subquery._values !== undefined) expr.list = expr.subquery._values;
|
|
1181
1239
|
if (!expr.list) return false;
|
|
1182
1240
|
return expr.list.some(x => x === v || String(x) === String(v));
|
|
1183
1241
|
}
|
|
1184
|
-
case 'like': return likeMatch(resolveOperand(expr.operand, row), expr.pattern);
|
|
1242
|
+
case 'like': return likeMatch(resolveOperand(expr.operand, row, ctx), expr.pattern);
|
|
1185
1243
|
case 'between': {
|
|
1186
|
-
const v = resolveOperand(expr.operand, row);
|
|
1187
|
-
const lo = resolveOperand(expr.low, row);
|
|
1188
|
-
const hi = resolveOperand(expr.high, row);
|
|
1244
|
+
const v = resolveOperand(expr.operand, row, ctx);
|
|
1245
|
+
const lo = resolveOperand(expr.low, row, ctx);
|
|
1246
|
+
const hi = resolveOperand(expr.high, row, ctx);
|
|
1189
1247
|
if (v === null || lo === null || hi === null) return false;
|
|
1190
1248
|
const inRange = typeof v === 'number' && typeof lo === 'number' && typeof hi === 'number'
|
|
1191
1249
|
? v >= lo && v <= hi
|
|
@@ -1194,20 +1252,20 @@ function evaluateExpr(expr, row) {
|
|
|
1194
1252
|
}
|
|
1195
1253
|
case 'case': {
|
|
1196
1254
|
for (const b of expr.branches) {
|
|
1197
|
-
if (evaluateExpr(b.cond, row)) return resolveOperand(b.val, row);
|
|
1255
|
+
if (evaluateExpr(b.cond, row, ctx)) return resolveOperand(b.val, row, ctx);
|
|
1198
1256
|
}
|
|
1199
|
-
return expr.elseVal ? resolveOperand(expr.elseVal, row) : null;
|
|
1257
|
+
return expr.elseVal ? resolveOperand(expr.elseVal, row, ctx) : null;
|
|
1200
1258
|
}
|
|
1201
1259
|
default: return false;
|
|
1202
1260
|
}
|
|
1203
1261
|
}
|
|
1204
1262
|
|
|
1205
|
-
function evaluateCaseVal(caseExpr, row) {
|
|
1263
|
+
function evaluateCaseVal(caseExpr, row, ctx) {
|
|
1206
1264
|
if (!caseExpr || caseExpr.type !== 'case') return undefined;
|
|
1207
1265
|
for (const b of caseExpr.branches) {
|
|
1208
|
-
if (evaluateExpr(b.cond, row)) return resolveOperand(b.val, row);
|
|
1266
|
+
if (evaluateExpr(b.cond, row, ctx)) return resolveOperand(b.val, row, ctx);
|
|
1209
1267
|
}
|
|
1210
|
-
return caseExpr.elseVal ? resolveOperand(caseExpr.elseVal, row) : null;
|
|
1268
|
+
return caseExpr.elseVal ? resolveOperand(caseExpr.elseVal, row, ctx) : null;
|
|
1211
1269
|
}
|
|
1212
1270
|
|
|
1213
1271
|
function scalarName(node) {
|
|
@@ -1217,7 +1275,7 @@ function scalarName(node) {
|
|
|
1217
1275
|
const dot = node.name.indexOf('.');
|
|
1218
1276
|
return dot !== -1 ? node.name.slice(dot + 1) : node.name;
|
|
1219
1277
|
}
|
|
1220
|
-
case 'func': return node.name;
|
|
1278
|
+
case 'func': return node.name + '()';
|
|
1221
1279
|
case 'value':
|
|
1222
1280
|
case 'literal': return String(node.value);
|
|
1223
1281
|
case 'arith': return scalarName(node.left) + ' ' + node.op + ' ' + scalarName(node.right);
|
|
@@ -1239,12 +1297,12 @@ function scalarColumnName(c) {
|
|
|
1239
1297
|
}
|
|
1240
1298
|
|
|
1241
1299
|
function scalarColumnValue(c, r, ctx) {
|
|
1242
|
-
if (c.scalar) return resolveOperand(c.scalar, r);
|
|
1300
|
+
if (c.scalar) return resolveOperand(c.scalar, r, ctx);
|
|
1243
1301
|
if (c.aggregate) return ctx._aggValue(ctx.group, c.aggregate || 'COUNT', c.column);
|
|
1244
1302
|
if (c.literal !== undefined) return c.literal;
|
|
1245
|
-
if (c.caseExpr) return evaluateCaseVal(c.caseExpr, r);
|
|
1303
|
+
if (c.caseExpr) return evaluateCaseVal(c.caseExpr, r, ctx);
|
|
1246
1304
|
if (c.expr === '*') return r[Object.keys(r).find(k => !k.startsWith('_'))];
|
|
1247
|
-
return resolveOperand({ type: 'column', name: c.expr }, r);
|
|
1305
|
+
return resolveOperand({ type: 'column', name: c.expr }, r, ctx);
|
|
1248
1306
|
}
|
|
1249
1307
|
|
|
1250
1308
|
function sqlTypeName(type) {
|
|
@@ -1288,8 +1346,9 @@ function normalizeRow(row, schema) { if (row && typeof row === 'object' && row.
|
|
|
1288
1346
|
}
|
|
1289
1347
|
|
|
1290
1348
|
class SQLExecutor {
|
|
1291
|
-
constructor(engine) {
|
|
1349
|
+
constructor(engine, ctx) {
|
|
1292
1350
|
this.engine = engine;
|
|
1351
|
+
this.ctx = ctx || null;
|
|
1293
1352
|
}
|
|
1294
1353
|
|
|
1295
1354
|
async execute(statement) {
|
|
@@ -1431,7 +1490,7 @@ class SQLExecutor {
|
|
|
1431
1490
|
const all = (await this.engine.find(statement.table, {}, { limit: 1e9, offset: 0 })).map(r => normalizeRow(r, schema));
|
|
1432
1491
|
let count = 0;
|
|
1433
1492
|
for (const row of all) {
|
|
1434
|
-
if (!statement.where || evaluateExpr(statement.where, row)) {
|
|
1493
|
+
if (!statement.where || evaluateExpr(statement.where, row, this.ctx)) {
|
|
1435
1494
|
const id = row._rid !== undefined ? row._rid : row.id;
|
|
1436
1495
|
if (id !== undefined) {
|
|
1437
1496
|
const data = {};
|
|
@@ -1451,7 +1510,7 @@ class SQLExecutor {
|
|
|
1451
1510
|
const all = (await this.engine.find(statement.table, {}, { limit: 1e9, offset: 0 })).map(r => normalizeRow(r, schema));
|
|
1452
1511
|
const ids = [];
|
|
1453
1512
|
for (const row of all) {
|
|
1454
|
-
if (!statement.where || evaluateExpr(statement.where, row)) {
|
|
1513
|
+
if (!statement.where || evaluateExpr(statement.where, row, this.ctx)) {
|
|
1455
1514
|
const id = row._rid !== undefined ? row._rid : row.id;
|
|
1456
1515
|
if (id !== undefined) ids.push(id);
|
|
1457
1516
|
}
|
|
@@ -1558,8 +1617,19 @@ class SQLExecutor {
|
|
|
1558
1617
|
return { ok: true, type: 'showGrants', columns: ['Grants for root@localhost'], rows: ['GRANT ALL PRIVILEGES ON *.* TO `root`@`localhost`'].map(g => [g]) };
|
|
1559
1618
|
case 'showWarnings':
|
|
1560
1619
|
return { ok: true, type: 'showWarnings', columns: ['Level', 'Code', 'Message'], rows: [] };
|
|
1561
|
-
case 'set':
|
|
1620
|
+
case 'set': {
|
|
1621
|
+
const session = this.ctx && this.ctx.session;
|
|
1622
|
+
if (session) {
|
|
1623
|
+
const raw = String(statement.raw || '');
|
|
1624
|
+
const m = raw.match(/^\s*([A-Za-z0-9_]+)(?:\.[A-Za-z0-9_]+)?\s*=\s*(.+)$/);
|
|
1625
|
+
if (m) {
|
|
1626
|
+
let val = m[2].trim().replace(/^'|'$/g, '').replace(/^"|"$/g, '');
|
|
1627
|
+
if (/^-?\d+(\.\d+)?$/.test(val)) val = Number(val);
|
|
1628
|
+
session.sysvars[m[1].toLowerCase()] = val;
|
|
1629
|
+
}
|
|
1630
|
+
}
|
|
1562
1631
|
return { ok: true, type: 'set', raw: statement.raw };
|
|
1632
|
+
}
|
|
1563
1633
|
case 'createDatabase': {
|
|
1564
1634
|
if (!this.engine.createDatabase) throw new Error('CREATE DATABASE is not supported by this engine');
|
|
1565
1635
|
await this.engine.createDatabase(statement.database, { ifNotExists: statement.ifNotExists });
|
|
@@ -1662,7 +1732,7 @@ class SQLExecutor {
|
|
|
1662
1732
|
_aggValue(rows, fn, column) {
|
|
1663
1733
|
if (fn === 'COUNT') return rows.length;
|
|
1664
1734
|
const op = typeof column === 'string' ? { type: 'column', name: column } : column;
|
|
1665
|
-
const values = rows.map(r => resolveOperand(op, r)).filter(v => v !== null && v !== undefined);
|
|
1735
|
+
const values = rows.map(r => resolveOperand(op, r, this.ctx)).filter(v => v !== null && v !== undefined);
|
|
1666
1736
|
if (fn === 'SUM') return values.reduce((s, v) => s + (typeof v === 'number' ? v : Number(v) || 0), 0);
|
|
1667
1737
|
if (fn === 'AVG') return values.length ? values.reduce((s, v) => s + (typeof v === 'number' ? v : Number(v) || 0), 0) / values.length : null;
|
|
1668
1738
|
if (fn === 'MIN') return values.length ? Math.min(...values.map(v => Number(v))) : null;
|
|
@@ -1789,14 +1859,14 @@ class SQLExecutor {
|
|
|
1789
1859
|
|
|
1790
1860
|
let rows = all;
|
|
1791
1861
|
if (statement.where) {
|
|
1792
|
-
rows = rows.filter(r => evaluateExpr(statement.where, r));
|
|
1862
|
+
rows = rows.filter(r => evaluateExpr(statement.where, r, this.ctx));
|
|
1793
1863
|
}
|
|
1794
1864
|
|
|
1795
1865
|
// 分组聚合
|
|
1796
1866
|
if (statement.groupBy) {
|
|
1797
1867
|
const groups = new Map();
|
|
1798
1868
|
for (const row of rows) {
|
|
1799
|
-
const key = JSON.stringify(statement.groupBy.map(g => resolveOperand({ type: 'column', name: g }, row)));
|
|
1869
|
+
const key = JSON.stringify(statement.groupBy.map(g => resolveOperand({ type: 'column', name: g }, row, this.ctx)));
|
|
1800
1870
|
if (!groups.has(key)) groups.set(key, []);
|
|
1801
1871
|
groups.get(key).push(row);
|
|
1802
1872
|
}
|
|
@@ -1831,10 +1901,10 @@ class SQLExecutor {
|
|
|
1831
1901
|
const seen = new Set();
|
|
1832
1902
|
rows = rows.filter(r => {
|
|
1833
1903
|
const key = JSON.stringify(statement.columns.map(c => {
|
|
1834
|
-
if (c.scalar) return resolveOperand(c.scalar, r);
|
|
1904
|
+
if (c.scalar) return resolveOperand(c.scalar, r, this.ctx);
|
|
1835
1905
|
if (c.caseExpr) return evaluateCaseVal(c.caseExpr, r);
|
|
1836
1906
|
if (c.aggregate) return this._aggValue(r._group || [r], c.aggregate || 'COUNT', c.column);
|
|
1837
|
-
return resolveOperand({ type: 'column', name: c.expr }, r);
|
|
1907
|
+
return resolveOperand({ type: 'column', name: c.expr }, r, this.ctx);
|
|
1838
1908
|
}));
|
|
1839
1909
|
if (seen.has(key)) return false;
|
|
1840
1910
|
seen.add(key);
|
|
@@ -1843,12 +1913,16 @@ class SQLExecutor {
|
|
|
1843
1913
|
}
|
|
1844
1914
|
|
|
1845
1915
|
// 聚合输出(无 GROUP BY 时)
|
|
1846
|
-
if (statement.
|
|
1847
|
-
const
|
|
1848
|
-
const
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
|
|
1916
|
+
if (!statement.groupBy && statement.columns.some(c => c.aggregate)) {
|
|
1917
|
+
const cols = statement.columns.map(c => scalarColumnName(c));
|
|
1918
|
+
const valueOf = (c) => {
|
|
1919
|
+
if (c.aggregate) return this._aggValue(rows, c.aggregate || 'COUNT', c.column);
|
|
1920
|
+
if (c.scalar) return resolveOperand(c.scalar, rows[0] || {}, this.ctx);
|
|
1921
|
+
if (c.caseExpr) return resolveOperand(c.caseExpr, rows[0] || {}, this.ctx);
|
|
1922
|
+
if (c.expr !== null && c.expr !== '*') return rows[0] ? resolveOperand({ type: 'column', name: c.expr }, rows[0]) : null;
|
|
1923
|
+
return null;
|
|
1924
|
+
};
|
|
1925
|
+
return { ok: true, type: 'select', table: statement.from ? (statement.from.tables[0].table || null) : null, columns: cols, rows: [[...statement.columns.map(valueOf)]], aggregate: statement.aggregate };
|
|
1852
1926
|
}
|
|
1853
1927
|
|
|
1854
1928
|
// GROUP BY 输出:每组的列(含聚合列)
|
|
@@ -1864,8 +1938,8 @@ class SQLExecutor {
|
|
|
1864
1938
|
if (statement.orderBy) {
|
|
1865
1939
|
const cmp = (a, b) => {
|
|
1866
1940
|
for (const o of statement.orderBy) {
|
|
1867
|
-
const av = resolveOperand({ type: 'column', name: o.column }, a);
|
|
1868
|
-
const bv = resolveOperand({ type: 'column', name: o.column }, b);
|
|
1941
|
+
const av = resolveOperand({ type: 'column', name: o.column }, a, this.ctx);
|
|
1942
|
+
const bv = resolveOperand({ type: 'column', name: o.column }, b, this.ctx);
|
|
1869
1943
|
if (av === bv || (av === undefined && bv === undefined)) continue;
|
|
1870
1944
|
if (av === undefined || av === null) return o.dir === 'asc' ? -1 : 1;
|
|
1871
1945
|
if (bv === undefined || bv === null) return o.dir === 'asc' ? 1 : -1;
|
|
@@ -1894,11 +1968,11 @@ class SQLExecutor {
|
|
|
1894
1968
|
|
|
1895
1969
|
const cols = statement.columns.map(c => scalarColumnName(c));
|
|
1896
1970
|
const mapped = rows.map(r => statement.columns.map(c => {
|
|
1897
|
-
if (c.scalar) return resolveOperand(c.scalar, r);
|
|
1971
|
+
if (c.scalar) return resolveOperand(c.scalar, r, this.ctx);
|
|
1898
1972
|
if (c.expr === '*') return null;
|
|
1899
1973
|
if (c.literal !== undefined) return c.literal;
|
|
1900
1974
|
if (c.caseExpr) return evaluateCaseVal(c.caseExpr, r);
|
|
1901
|
-
return resolveOperand({ type: 'column', name: c.expr }, r);
|
|
1975
|
+
return resolveOperand({ type: 'column', name: c.expr }, r, this.ctx);
|
|
1902
1976
|
}));
|
|
1903
1977
|
return { ok: true, type: 'select', table: tableName, columns: cols, rows: mapped, raw: rows };
|
|
1904
1978
|
}
|
|
@@ -2068,7 +2142,7 @@ async function executeSQL(engine, sql, paramsOrOpts, opts = {}) {
|
|
|
2068
2142
|
if (dangerous) throw new Error(`SQL statement blocked by security policy: ${dangerous}`);
|
|
2069
2143
|
}
|
|
2070
2144
|
}
|
|
2071
|
-
const executor = new SQLExecutor(engine);
|
|
2145
|
+
const executor = new SQLExecutor(engine, opts.session ? { session: opts.session } : null);
|
|
2072
2146
|
const results = [];
|
|
2073
2147
|
for (const stmtSql of statements) {
|
|
2074
2148
|
const stmt = parseSQL(stmtSql);
|
package/lib/web_ui.js
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* JSQL-NEO built-in web UI: zero-dependency HTTP management console.
|
|
3
|
+
*
|
|
4
|
+
* const { WebUI } = require('jsql-neo');
|
|
5
|
+
* const ui = new WebUI({ port: 8080, dataDir: './data' });
|
|
6
|
+
* await ui.start();
|
|
7
|
+
*
|
|
8
|
+
* Routes:
|
|
9
|
+
* GET / management console (HTML, no external deps)
|
|
10
|
+
* GET /api/databases [{name, tables, rows}]
|
|
11
|
+
* GET /api/tables?db=name {tables: [{name, count}]}
|
|
12
|
+
* POST /api/query {db, sql} {columns, rows, rowCount, ok, error?}
|
|
13
|
+
*/
|
|
14
|
+
const http = require('http');
|
|
15
|
+
const fs = require('fs');
|
|
16
|
+
const path = require('path');
|
|
17
|
+
const Database = require('./database');
|
|
18
|
+
const { executeSQL } = require('./sql');
|
|
19
|
+
|
|
20
|
+
const PAGE = `<!DOCTYPE html>
|
|
21
|
+
<html lang="en">
|
|
22
|
+
<head>
|
|
23
|
+
<meta charset="utf-8">
|
|
24
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
25
|
+
<title>JSQL-NEO</title>
|
|
26
|
+
<style>
|
|
27
|
+
:root { color-scheme: dark; }
|
|
28
|
+
body { margin: 0; font: 14px/1.5 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; background: #0d1117; color: #e6edf3; display: flex; height: 100vh; }
|
|
29
|
+
.side { width: 260px; border-right: 1px solid #21262d; padding: 12px; overflow: auto; }
|
|
30
|
+
.main { flex: 1; display: flex; flex-direction: column; }
|
|
31
|
+
h1 { font-size: 15px; margin: 4px 0 12px; }
|
|
32
|
+
h2 { font-size: 12px; color: #8b949e; text-transform: uppercase; letter-spacing: 1px; margin: 14px 0 6px; }
|
|
33
|
+
.db { cursor: pointer; padding: 3px 6px; border-radius: 6px; }
|
|
34
|
+
.db:hover, .db.active { background: #1f6feb33; }
|
|
35
|
+
.db .rows { color: #8b949e; font-size: 12px; }
|
|
36
|
+
.tbl { cursor: pointer; padding: 2px 6px 2px 18px; color: #79c0ff; border-radius: 4px; }
|
|
37
|
+
.tbl:hover { background: #21262d; }
|
|
38
|
+
textarea { flex: 1; margin: 12px; padding: 10px; background: #010409; color: #e6edf3; border: 1px solid #21262d; border-radius: 8px; resize: none; font: inherit; }
|
|
39
|
+
.actions { padding: 0 12px; }
|
|
40
|
+
button { background: #238636; border: 0; color: #fff; padding: 6px 16px; border-radius: 6px; cursor: pointer; font: inherit; }
|
|
41
|
+
button:hover { background: #2ea043; }
|
|
42
|
+
button:disabled { background: #21262d; cursor: wait; }
|
|
43
|
+
.status { padding: 0 12px 10px; color: #8b949e; min-height: 20px; }
|
|
44
|
+
table { border-collapse: collapse; width: 100%; font-size: 13px; }
|
|
45
|
+
th, td { border: 1px solid #21262d; padding: 4px 8px; text-align: left; white-space: pre; }
|
|
46
|
+
th { background: #161b22; position: sticky; top: 0; }
|
|
47
|
+
tr:nth-child(even) td { background: #0d1117; }
|
|
48
|
+
.result { flex: 1.4; overflow: auto; margin: 0 12px 12px; border: 1px solid #21262d; border-radius: 8px; background: #010409; }
|
|
49
|
+
.err { color: #f85149; padding: 10px; }
|
|
50
|
+
.ok { color: #3fb950; }
|
|
51
|
+
</style>
|
|
52
|
+
</head>
|
|
53
|
+
<body>
|
|
54
|
+
<div class="side">
|
|
55
|
+
<h1>JSQL-NEO</h1>
|
|
56
|
+
<h2>Databases</h2>
|
|
57
|
+
<div id="dbs"></div>
|
|
58
|
+
<h2>Tables</h2>
|
|
59
|
+
<div id="tbls"></div>
|
|
60
|
+
</div>
|
|
61
|
+
<div class="main">
|
|
62
|
+
<textarea id="sql" spellcheck="false" placeholder="SELECT * FROM t LIMIT 100">SELECT 1</textarea>
|
|
63
|
+
<div class="actions">
|
|
64
|
+
<button id="run" onclick="run()">Run (Ctrl+Enter)</button>
|
|
65
|
+
</div>
|
|
66
|
+
<div class="status" id="status"></div>
|
|
67
|
+
<div class="result" id="res"></div>
|
|
68
|
+
</div>
|
|
69
|
+
<script>
|
|
70
|
+
let dbs = [], cur = null;
|
|
71
|
+
const q = async (u, o) => { const r = await fetch(u, o); return r.json(); };
|
|
72
|
+
function esc(s) { return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>'); }
|
|
73
|
+
async function load() {
|
|
74
|
+
dbs = await q('/api/databases');
|
|
75
|
+
const el = document.getElementById('dbs');
|
|
76
|
+
el.innerHTML = dbs.map(d => '<div class="db" onclick="openDb(' + esc(d.name) + ')">' + esc(d.name) + ' <span class="rows">(' + d.tables + ' tbl)</span></div>').join('');
|
|
77
|
+
if (!cur && dbs.length) openDb(dbs[0].name);
|
|
78
|
+
}
|
|
79
|
+
async function openDb(n) {
|
|
80
|
+
cur = n;
|
|
81
|
+
document.querySelectorAll('.db').forEach(e => e.classList.toggle('active', e.textContent.indexOf(n) === 0));
|
|
82
|
+
const r = await q('/api/tables?db=' + encodeURIComponent(n));
|
|
83
|
+
document.getElementById('tbls').innerHTML = r.tables.map(t =>
|
|
84
|
+
'<div class="tbl" onclick="sel(' + esc(t.name) + ')">' + esc(t.name) + ' (' + t.count + ')</div>').join('');
|
|
85
|
+
}
|
|
86
|
+
function sel(n) { document.getElementById('sql').value = 'SELECT * FROM ' + n + ' LIMIT 100'; run(); }
|
|
87
|
+
async function run() {
|
|
88
|
+
const sql = document.getElementById('sql').value;
|
|
89
|
+
if (!cur || !sql.trim()) return;
|
|
90
|
+
const btn = document.getElementById('run'); btn.disabled = true;
|
|
91
|
+
const st = document.getElementById('status'); st.innerHTML = 'running...';
|
|
92
|
+
try {
|
|
93
|
+
const r = await q('/api/query', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ db: cur, sql }) });
|
|
94
|
+
st.innerHTML = r.ok ? '<span class="ok">ok</span> ' + r.rowCount + ' row(s), ' + r.ms + 'ms' : '<span class="err">' + esc(r.error) + '</span>';
|
|
95
|
+
if (r.columns && r.columns.length) {
|
|
96
|
+
let h = '<table><tr>' + r.columns.map(c => '<th>' + esc(c) + '</th>').join('') + '</tr>';
|
|
97
|
+
h += r.rows.map(row => '<tr>' + row.map(c => '<td>' + (c === null ? '<i>NULL</i>' : esc(c)) + '</td>').join('') + '</tr>').join('');
|
|
98
|
+
document.getElementById('res').innerHTML = h + '</table>';
|
|
99
|
+
} else if (r.affected !== undefined) {
|
|
100
|
+
document.getElementById('res').innerHTML = '<p class="ok">' + r.affected + ' row(s) affected</p>';
|
|
101
|
+
}
|
|
102
|
+
} catch (e) { st.innerHTML = '<span class="err">' + esc(e.message) + '</span>'; }
|
|
103
|
+
btn.disabled = false;
|
|
104
|
+
}
|
|
105
|
+
document.addEventListener('keydown', e => { if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') run(); });
|
|
106
|
+
load();
|
|
107
|
+
</script>
|
|
108
|
+
</body>
|
|
109
|
+
</html>
|
|
110
|
+
`;
|
|
111
|
+
|
|
112
|
+
class WebUI {
|
|
113
|
+
constructor(opts = {}) {
|
|
114
|
+
this.port = opts.port || 8080;
|
|
115
|
+
this.dataDir = opts.dataDir || '.';
|
|
116
|
+
this.readonly = !!opts.readonly;
|
|
117
|
+
this.host = opts.host || '0.0.0.0';
|
|
118
|
+
this.cache = new Map();
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
dbPath(name) {
|
|
122
|
+
if (!/^[A-Za-z0-9_.-]+$/.test(name)) throw new Error('invalid database name');
|
|
123
|
+
const p = path.join(this.dataDir, name + '.json');
|
|
124
|
+
if (!fs.existsSync(p)) throw new Error('database not found: ' + name);
|
|
125
|
+
return p;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async db(name) {
|
|
129
|
+
if (this.cache.has(name)) return this.cache.get(name);
|
|
130
|
+
const db = new Database(this.dbPath(name), { autoSave: !this.readonly });
|
|
131
|
+
if (db.loadDatabase) await db.loadDatabase();
|
|
132
|
+
this.cache.set(name, db);
|
|
133
|
+
return db;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
listDatabases() {
|
|
137
|
+
if (!fs.existsSync(this.dataDir)) return [];
|
|
138
|
+
return fs.readdirSync(this.dataDir)
|
|
139
|
+
.filter(f => f.endsWith('.json'))
|
|
140
|
+
.map(f => {
|
|
141
|
+
let tables = 0;
|
|
142
|
+
try { tables = Object.keys(JSON.parse(fs.readFileSync(path.join(this.dataDir, f), 'utf8')).__schema__ || {}).length; } catch (_) {}
|
|
143
|
+
return { name: f.slice(0, -5), tables };
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
tableList(db) {
|
|
148
|
+
const map = db._tables || {};
|
|
149
|
+
return Object.values(map).map(t => ({
|
|
150
|
+
name: t._name || t.name,
|
|
151
|
+
count: (t._rows || t.rows || []).length,
|
|
152
|
+
}));
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async handle(req, res) {
|
|
156
|
+
const url = new URL(req.url, 'http://x');
|
|
157
|
+
const send = (code, obj) => {
|
|
158
|
+
const body = JSON.stringify(obj);
|
|
159
|
+
res.writeHead(code, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' });
|
|
160
|
+
res.end(body);
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
try {
|
|
164
|
+
if (url.pathname === '/' && req.method === 'GET') {
|
|
165
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
166
|
+
return res.end(PAGE);
|
|
167
|
+
}
|
|
168
|
+
if (url.pathname === '/api/databases' && req.method === 'GET') {
|
|
169
|
+
return send(200, this.listDatabases());
|
|
170
|
+
}
|
|
171
|
+
if (url.pathname === '/api/tables' && req.method === 'GET') {
|
|
172
|
+
const db = await this.db(url.searchParams.get("db"));
|
|
173
|
+
return send(200, { tables: this.tableList(db) });
|
|
174
|
+
}
|
|
175
|
+
if (url.pathname === '/api/query' && req.method === 'POST') {
|
|
176
|
+
let body = '';
|
|
177
|
+
for await (const chunk of req) body += chunk;
|
|
178
|
+
const { db: dbName, sql } = JSON.parse(body || '{}');
|
|
179
|
+
if (!dbName || !sql) return send(400, { ok: false, error: 'db and sql are required' });
|
|
180
|
+
const db = await this.db(dbName);
|
|
181
|
+
const t0 = Date.now();
|
|
182
|
+
try {
|
|
183
|
+
const res2 = await executeSQL(db, sql);
|
|
184
|
+
const ms = Date.now() - t0;
|
|
185
|
+
const out = { ok: true, ms };
|
|
186
|
+
if (res2 && res2.rows) {
|
|
187
|
+
out.columns = res2.columns;
|
|
188
|
+
out.rows = res2.rows;
|
|
189
|
+
out.rowCount = res2.rows.length;
|
|
190
|
+
} else if (res2 && res2.affectedRows !== undefined) {
|
|
191
|
+
out.affected = res2.affectedRows;
|
|
192
|
+
} else {
|
|
193
|
+
out.rowCount = 0;
|
|
194
|
+
}
|
|
195
|
+
return send(200, out);
|
|
196
|
+
} catch (e) {
|
|
197
|
+
return send(200, { ok: false, error: String(e.message || e) });
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
return send(404, { ok: false, error: 'not found' });
|
|
201
|
+
} catch (e) {
|
|
202
|
+
return send(500, { ok: false, error: String(e.message || e) });
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
start() {
|
|
207
|
+
return new Promise((resolve, reject) => {
|
|
208
|
+
this.server = http.createServer((req, res) => this.handle(req, res).catch(e => {
|
|
209
|
+
try { res.writeHead(500, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ ok: false, error: String(e.message || e) })); } catch (_) {}
|
|
210
|
+
}));
|
|
211
|
+
this.server.on('error', reject);
|
|
212
|
+
this.server.listen(this.port, this.host, () => resolve(this.server.address().port));
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
stop() {
|
|
217
|
+
return new Promise(resolve => {
|
|
218
|
+
for (const db of this.cache.values()) db.stop();
|
|
219
|
+
this.cache.clear();
|
|
220
|
+
if (this.server) this.server.close(() => resolve());
|
|
221
|
+
else resolve();
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
module.exports = { WebUI };
|