usql 1.0.2 → 1.0.5
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/.github/workflows/publish.yml +23 -16
- package/.github/workflows/test.yml +8 -7
- package/.tool-versions +1 -0
- package/README.md +298 -90
- package/dist/index.js +1 -1
- package/index.d.ts +286 -0
- package/package.json +11 -11
- package/src/db.js +302 -51
- package/src/raw.js +14 -0
- package/tests/index.spec.js +232 -5
package/tests/index.spec.js
CHANGED
|
@@ -50,19 +50,19 @@ FROM `table` JOIN table2 as t2 ON `table`.`column` = `t2`.`item_id`'
|
|
|
50
50
|
it('text', async () => {
|
|
51
51
|
const sql = new DB('table').where({ 'table.column': '5' }).where({ 'column.column2': '4' })
|
|
52
52
|
|
|
53
|
-
expect(sql.toString()).toEqual('SELECT * FROM `table` WHERE `table`.`column` = "5" AND `column`.`column2` =
|
|
53
|
+
expect(sql.toString()).toEqual('SELECT * FROM `table` WHERE `table`.`column` = "5" AND `column`.`column2` = "4"')
|
|
54
54
|
})
|
|
55
55
|
|
|
56
56
|
it('number', async () => {
|
|
57
57
|
const sql = new DB('table').where({ 'table.column': 5 }).where({ 'column.column2': '4' })
|
|
58
58
|
|
|
59
|
-
expect(sql.toString()).toEqual('SELECT * FROM `table` WHERE `table`.`column` = "5" AND `column`.`column2` =
|
|
59
|
+
expect(sql.toString()).toEqual('SELECT * FROM `table` WHERE `table`.`column` = "5" AND `column`.`column2` = "4"')
|
|
60
60
|
})
|
|
61
61
|
|
|
62
62
|
it('null', async () => {
|
|
63
63
|
const sql = new DB('table').where({ 'table.column': null }).where({ 'column.column2': '4' })
|
|
64
64
|
|
|
65
|
-
expect(sql.toString()).toEqual('SELECT * FROM `table` WHERE `table`.`column` IS NULL AND `column`.`column2` =
|
|
65
|
+
expect(sql.toString()).toEqual('SELECT * FROM `table` WHERE `table`.`column` IS NULL AND `column`.`column2` = "4"')
|
|
66
66
|
})
|
|
67
67
|
})
|
|
68
68
|
|
|
@@ -119,7 +119,7 @@ FROM `table` JOIN table2 as t2 ON `table`.`column` = `t2`.`item_id`'
|
|
|
119
119
|
|
|
120
120
|
expect(sql.toString()).toEqual('\
|
|
121
121
|
SELECT * FROM `table` \
|
|
122
|
-
WHERE `table`.`column1` = "1" AND `table`.`column3` =
|
|
122
|
+
WHERE `table`.`column1` = "1" AND `table`.`column3` = "3" OR `table`.`column2` = "2" \
|
|
123
123
|
AND `table`.`column3` = "3" OR `table`.`column4` = "4" \
|
|
124
124
|
AND `table`.`column3` = "5"')
|
|
125
125
|
})
|
|
@@ -192,10 +192,11 @@ AND `table`.`column3` = "5"')
|
|
|
192
192
|
})
|
|
193
193
|
})
|
|
194
194
|
|
|
195
|
+
// Direction is now normalised to uppercase
|
|
195
196
|
it('orderBy', async () => {
|
|
196
197
|
const sql = new DB('table1').orderBy('table1.column1_value', 'desc')
|
|
197
198
|
|
|
198
|
-
expect(sql.toString()).toEqual('SELECT * FROM `table1` ORDER BY `table1`.`column1_value`
|
|
199
|
+
expect(sql.toString()).toEqual('SELECT * FROM `table1` ORDER BY `table1`.`column1_value` DESC')
|
|
199
200
|
})
|
|
200
201
|
|
|
201
202
|
describe('limit & offset', () => {
|
|
@@ -217,6 +218,232 @@ AND `table`.`column3` = "5"')
|
|
|
217
218
|
expect(sql.toString()).toEqual('SELECT * FROM `table1` LIMIT 5, 2')
|
|
218
219
|
})
|
|
219
220
|
})
|
|
221
|
+
|
|
222
|
+
// ---------------------------------------------------------------------------
|
|
223
|
+
// toSQL — parameterised query output
|
|
224
|
+
// ---------------------------------------------------------------------------
|
|
225
|
+
describe('toSQL', () => {
|
|
226
|
+
it('returns sql and empty bindings for no-where query', () => {
|
|
227
|
+
const result = new DB('table').toSQL()
|
|
228
|
+
|
|
229
|
+
expect(result).toEqual({ sql: 'SELECT * FROM `table`', bindings: [] })
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
it('produces ? placeholders for where values', () => {
|
|
233
|
+
const result = new DB('table').where('id', 42).toSQL()
|
|
234
|
+
|
|
235
|
+
expect(result.sql).toEqual('SELECT * FROM `table` WHERE `id` = ?')
|
|
236
|
+
expect(result.bindings).toEqual([42])
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
it('collects multiple bindings in order', () => {
|
|
240
|
+
const result = new DB('table').where('a', 1).where('b', 'hello').toSQL()
|
|
241
|
+
|
|
242
|
+
expect(result.sql).toEqual('SELECT * FROM `table` WHERE `a` = ? AND `b` = ?')
|
|
243
|
+
expect(result.bindings).toEqual([1, 'hello'])
|
|
244
|
+
})
|
|
245
|
+
|
|
246
|
+
it('renders NULL inline (not as a placeholder) for null values', () => {
|
|
247
|
+
const result = new DB('table').where({ col: null }).toSQL()
|
|
248
|
+
|
|
249
|
+
expect(result.sql).toEqual('SELECT * FROM `table` WHERE `col` IS NULL')
|
|
250
|
+
expect(result.bindings).toEqual([])
|
|
251
|
+
})
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
// ---------------------------------------------------------------------------
|
|
255
|
+
// clone — instance isolation
|
|
256
|
+
// ---------------------------------------------------------------------------
|
|
257
|
+
describe('clone', () => {
|
|
258
|
+
it('produces identical SQL', () => {
|
|
259
|
+
const base = new DB('table').where('id', 1)
|
|
260
|
+
const copy = base.clone()
|
|
261
|
+
|
|
262
|
+
expect(copy.toString()).toEqual(base.toString())
|
|
263
|
+
})
|
|
264
|
+
|
|
265
|
+
it('mutations on clone do not affect original', () => {
|
|
266
|
+
const base = new DB('table').where('id', 1)
|
|
267
|
+
const copy = base.clone()
|
|
268
|
+
copy.where('role', 'admin')
|
|
269
|
+
|
|
270
|
+
expect(base.toString()).toEqual('SELECT * FROM `table` WHERE `id` = "1"')
|
|
271
|
+
expect(copy.toString()).toEqual('SELECT * FROM `table` WHERE `id` = "1" AND `role` = "admin"')
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
it('mutations on original do not affect clone', () => {
|
|
275
|
+
const base = new DB('table').where('id', 1)
|
|
276
|
+
const copy = base.clone()
|
|
277
|
+
base.limit(10)
|
|
278
|
+
|
|
279
|
+
expect(copy.toString()).toEqual('SELECT * FROM `table` WHERE `id` = "1"')
|
|
280
|
+
})
|
|
281
|
+
})
|
|
282
|
+
|
|
283
|
+
// ---------------------------------------------------------------------------
|
|
284
|
+
// whereGroup / orWhereGroup — parenthesised conditions
|
|
285
|
+
// ---------------------------------------------------------------------------
|
|
286
|
+
describe('whereGroup', () => {
|
|
287
|
+
it('wraps group conditions in parentheses (AND joiner)', () => {
|
|
288
|
+
const sql = new DB('table')
|
|
289
|
+
.where('active', 1)
|
|
290
|
+
.whereGroup((q) => q.where('role', 'admin').orWhere('role', 'moderator'))
|
|
291
|
+
|
|
292
|
+
expect(sql.toString()).toEqual(
|
|
293
|
+
'SELECT * FROM `table` WHERE `active` = "1" AND (`role` = "admin" OR `role` = "moderator")'
|
|
294
|
+
)
|
|
295
|
+
})
|
|
296
|
+
})
|
|
297
|
+
|
|
298
|
+
describe('orWhereGroup', () => {
|
|
299
|
+
it('wraps group conditions in parentheses (OR joiner)', () => {
|
|
300
|
+
const sql = new DB('table')
|
|
301
|
+
.where('is_deleted', 0)
|
|
302
|
+
.orWhereGroup((q) => q.where('role', 'superadmin').where('active', 1))
|
|
303
|
+
|
|
304
|
+
expect(sql.toString()).toEqual(
|
|
305
|
+
'SELECT * FROM `table` WHERE `is_deleted` = "0" OR (`role` = "superadmin" AND `active` = "1")'
|
|
306
|
+
)
|
|
307
|
+
})
|
|
308
|
+
})
|
|
309
|
+
|
|
310
|
+
// ---------------------------------------------------------------------------
|
|
311
|
+
// Security tests — every injection vector from the audit
|
|
312
|
+
// ---------------------------------------------------------------------------
|
|
313
|
+
describe('security', () => {
|
|
314
|
+
// -------------------------------------------------------------------------
|
|
315
|
+
// String escaping (was using global escape() URI-encoder)
|
|
316
|
+
// -------------------------------------------------------------------------
|
|
317
|
+
describe('string escaping', () => {
|
|
318
|
+
it('preserves non-ASCII characters without percent-encoding', () => {
|
|
319
|
+
const sql = new DB('table').where('name', 'café').toString()
|
|
320
|
+
// old escape() would produce caf%E9; correct escaping preserves the char
|
|
321
|
+
expect(sql).toContain('"café"')
|
|
322
|
+
expect(sql).not.toContain('%E9')
|
|
323
|
+
})
|
|
324
|
+
|
|
325
|
+
it('escapes double quotes inside string values', () => {
|
|
326
|
+
const sql = new DB('table').where('bio', 'say "hello"').toString()
|
|
327
|
+
expect(sql).toContain('\\"hello\\"')
|
|
328
|
+
})
|
|
329
|
+
|
|
330
|
+
it('escapes backslashes inside string values', () => {
|
|
331
|
+
const sql = new DB('table').where('path', 'C:\\Users\\admin').toString()
|
|
332
|
+
expect(sql).toContain('C:\\\\Users\\\\admin')
|
|
333
|
+
})
|
|
334
|
+
|
|
335
|
+
it('escapes newlines inside string values', () => {
|
|
336
|
+
const sql = new DB('table').where('note', 'line1\nline2').toString()
|
|
337
|
+
expect(sql).toContain('\\n')
|
|
338
|
+
})
|
|
339
|
+
})
|
|
340
|
+
|
|
341
|
+
// -------------------------------------------------------------------------
|
|
342
|
+
// orderBy direction injection
|
|
343
|
+
// -------------------------------------------------------------------------
|
|
344
|
+
describe('orderBy direction injection', () => {
|
|
345
|
+
it('throws RangeError on an injected direction payload', () => {
|
|
346
|
+
expect(() =>
|
|
347
|
+
new DB('table').orderBy('col', 'ASC LIMIT 0 UNION SELECT password FROM admin --')
|
|
348
|
+
).toThrow(RangeError)
|
|
349
|
+
})
|
|
350
|
+
|
|
351
|
+
it('throws RangeError on an empty direction string', () => {
|
|
352
|
+
expect(() => new DB('table').orderBy('col', '')).toThrow(RangeError)
|
|
353
|
+
})
|
|
354
|
+
|
|
355
|
+
it('accepts asc (case-insensitive) and normalises to ASC', () => {
|
|
356
|
+
const sql = new DB('table').orderBy('col', 'asc').toString()
|
|
357
|
+
expect(sql).toContain('ORDER BY `col` ASC')
|
|
358
|
+
})
|
|
359
|
+
})
|
|
360
|
+
|
|
361
|
+
// -------------------------------------------------------------------------
|
|
362
|
+
// limit / offset injection
|
|
363
|
+
// -------------------------------------------------------------------------
|
|
364
|
+
describe('limit injection', () => {
|
|
365
|
+
it('throws TypeError when limit is a SQL-injection string', () => {
|
|
366
|
+
expect(() =>
|
|
367
|
+
new DB('table').limit('10 UNION SELECT password FROM admin')
|
|
368
|
+
).toThrow(TypeError)
|
|
369
|
+
})
|
|
370
|
+
|
|
371
|
+
it('throws TypeError when limit is a non-numeric string', () => {
|
|
372
|
+
expect(() => new DB('table').limit('abc')).toThrow(TypeError)
|
|
373
|
+
})
|
|
374
|
+
})
|
|
375
|
+
|
|
376
|
+
describe('offset injection', () => {
|
|
377
|
+
it('throws TypeError when offset is a SQL-injection string', () => {
|
|
378
|
+
expect(() =>
|
|
379
|
+
new DB('table').offset('5; DROP TABLE users --')
|
|
380
|
+
).toThrow(TypeError)
|
|
381
|
+
})
|
|
382
|
+
})
|
|
383
|
+
|
|
384
|
+
// -------------------------------------------------------------------------
|
|
385
|
+
// WHERE operator injection
|
|
386
|
+
// -------------------------------------------------------------------------
|
|
387
|
+
describe('where operator injection', () => {
|
|
388
|
+
it('throws RangeError when operator contains a UNION payload', () => {
|
|
389
|
+
expect(() =>
|
|
390
|
+
new DB('table').where('id', '= 1 UNION SELECT password FROM admin --', 1)
|
|
391
|
+
).toThrow(RangeError)
|
|
392
|
+
})
|
|
393
|
+
|
|
394
|
+
it('throws RangeError for a bare semicolon operator', () => {
|
|
395
|
+
expect(() => new DB('table').where('id', ';', 1)).toThrow(RangeError)
|
|
396
|
+
})
|
|
397
|
+
|
|
398
|
+
it('allows all standard comparison operators', () => {
|
|
399
|
+
const ops = ['=', '!=', '<>', '<', '>', '<=', '>=', 'LIKE', 'NOT LIKE']
|
|
400
|
+
ops.forEach((op) => {
|
|
401
|
+
expect(() => new DB('table').where('col', op, 'val')).not.toThrow()
|
|
402
|
+
})
|
|
403
|
+
})
|
|
404
|
+
})
|
|
405
|
+
|
|
406
|
+
// -------------------------------------------------------------------------
|
|
407
|
+
// join operator injection
|
|
408
|
+
// -------------------------------------------------------------------------
|
|
409
|
+
describe('join operator injection', () => {
|
|
410
|
+
it('throws RangeError when join operator is an injection payload', () => {
|
|
411
|
+
expect(() =>
|
|
412
|
+
new DB('table').join('other', 'table.id', '= other.id UNION SELECT 1,2,3 --', 'other.id')
|
|
413
|
+
).toThrow(RangeError)
|
|
414
|
+
})
|
|
415
|
+
})
|
|
416
|
+
|
|
417
|
+
// -------------------------------------------------------------------------
|
|
418
|
+
// null / undefined column guard
|
|
419
|
+
// -------------------------------------------------------------------------
|
|
420
|
+
describe('null/undefined column guard', () => {
|
|
421
|
+
it('throws TypeError when column is null', () => {
|
|
422
|
+
expect(() => new DB('table').where(null, 'val')).toThrow(TypeError)
|
|
423
|
+
})
|
|
424
|
+
|
|
425
|
+
it('throws TypeError when column is undefined', () => {
|
|
426
|
+
expect(() => new DB('table').where(undefined, 'val')).toThrow(TypeError)
|
|
427
|
+
})
|
|
428
|
+
})
|
|
429
|
+
|
|
430
|
+
// -------------------------------------------------------------------------
|
|
431
|
+
// String "null" type confusion
|
|
432
|
+
// -------------------------------------------------------------------------
|
|
433
|
+
describe('"null" string type confusion', () => {
|
|
434
|
+
it('does NOT rewrite operator to IS when value is the string "null"', () => {
|
|
435
|
+
const sql = new DB('table').where('col', 'null').toString()
|
|
436
|
+
// Must produce = "null", not IS NULL
|
|
437
|
+
expect(sql).toContain('= "null"')
|
|
438
|
+
expect(sql).not.toContain('IS NULL')
|
|
439
|
+
})
|
|
440
|
+
|
|
441
|
+
it('DOES rewrite operator to IS when value is actual null', () => {
|
|
442
|
+
const sql = new DB('table').where({ col: null }).toString()
|
|
443
|
+
expect(sql).toContain('IS NULL')
|
|
444
|
+
})
|
|
445
|
+
})
|
|
446
|
+
})
|
|
220
447
|
})
|
|
221
448
|
|
|
222
449
|
/* eslint-enable no-multi-str */
|