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/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.aggregate && !statement.groupBy) {
1847
- const agg = statement.aggregate;
1848
- const rowsOnly = rows;
1849
- const aggName = agg.alias || (agg.type === 'COUNT' ? 'COUNT(*)' : agg.type + '(' + agg.column + ')');
1850
- const value = this._aggValue(rowsOnly, agg.type, agg.column);
1851
- return { ok: true, type: 'select', table: statement.from ? (statement.from.tables[0].table || null) : null, columns: [aggName], rows: [[value]], aggregate: agg };
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;'); }
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 };