mirror of
https://github.com/nodejs/node.git
synced 2026-10-28 18:25:13 +08:00
`Database.prototype.aggregate()` cast its arguments without checking their types, so a missing `options` leaked a raw V8 TypeError and a non-string `name` was silently coerced. Throw `ERR_INVALID_ARG_TYPE` like the other sqlite bindings do. Assisted-by: Claude Code Signed-off-by: Guilherme Araújo <arauujogui@gmail.com> PR-URL: https://github.com/nodejs/node/pull/66463 Reviewed-By: Trivikram Kamat <trivikr.dev@gmail.com>
491 lines
13 KiB
JavaScript
491 lines
13 KiB
JavaScript
import { skipIfSQLiteMissing } from '../common/index.mjs';
|
|
import { describe, test } from 'node:test';
|
|
skipIfSQLiteMissing();
|
|
const { Database } = await import('node:sqlite');
|
|
|
|
describe('Database.prototype.aggregate()', () => {
|
|
describe('input validation', () => {
|
|
const db = new Database(':memory:');
|
|
|
|
// The length property is configurable, so any type can reach the
|
|
// conversion that derives the aggregate's arity from it.
|
|
const lengths = ['abc', {}, [], null, undefined, NaN, 1.5,
|
|
Symbol.iterator, 10n, true, 2 ** 40];
|
|
|
|
function withLength(fn, length) {
|
|
Object.defineProperty(fn, 'length', {
|
|
configurable: true,
|
|
value: length,
|
|
});
|
|
return fn;
|
|
}
|
|
|
|
test('throws if name is not a string', (t) => {
|
|
t.assert.throws(() => {
|
|
db.aggregate(123, { start: 0, step: () => {} });
|
|
}, {
|
|
code: 'ERR_INVALID_ARG_TYPE',
|
|
message: 'The "name" argument must be a string.'
|
|
});
|
|
});
|
|
|
|
test('throws if options is not an object', (t) => {
|
|
t.assert.throws(() => {
|
|
db.aggregate('sum');
|
|
}, {
|
|
code: 'ERR_INVALID_ARG_TYPE',
|
|
message: 'The "options" argument must be an object.'
|
|
});
|
|
});
|
|
|
|
test('throws if options.start is not provided', (t) => {
|
|
t.assert.throws(() => {
|
|
db.aggregate('sum', {
|
|
result: (total) => total
|
|
});
|
|
}, {
|
|
code: 'ERR_INVALID_ARG_TYPE',
|
|
message: 'The "options.start" argument must be a function or a primitive value.'
|
|
});
|
|
});
|
|
|
|
test('throws if options.step is not a function', (t) => {
|
|
t.assert.throws(() => {
|
|
db.aggregate('sum', {
|
|
start: 0,
|
|
result: (total) => total
|
|
});
|
|
}, {
|
|
code: 'ERR_INVALID_ARG_TYPE',
|
|
message: 'The "options.step" argument must be a function.'
|
|
});
|
|
});
|
|
|
|
test('throws if options.useBigIntArguments is not a boolean', (t) => {
|
|
t.assert.throws(() => {
|
|
db.aggregate('sum', {
|
|
start: 0,
|
|
step: () => null,
|
|
useBigIntArguments: ''
|
|
});
|
|
}, {
|
|
code: 'ERR_INVALID_ARG_TYPE',
|
|
message: /The "options\.useBigIntArguments" argument must be a boolean/,
|
|
});
|
|
});
|
|
|
|
test('throws if options.varargs is not a boolean', (t) => {
|
|
t.assert.throws(() => {
|
|
db.aggregate('sum', {
|
|
start: 0,
|
|
step: () => null,
|
|
varargs: ''
|
|
});
|
|
}, {
|
|
code: 'ERR_INVALID_ARG_TYPE',
|
|
message: /The "options\.varargs" argument must be a boolean/,
|
|
});
|
|
});
|
|
|
|
test('throws if options.directOnly is not a boolean', (t) => {
|
|
t.assert.throws(() => {
|
|
db.aggregate('sum', {
|
|
start: 0,
|
|
step: () => null,
|
|
directOnly: ''
|
|
});
|
|
}, {
|
|
code: 'ERR_INVALID_ARG_TYPE',
|
|
message: /The "options\.directOnly" argument must be a boolean/,
|
|
});
|
|
});
|
|
|
|
test('throws if options.inverse is not a function', (t) => {
|
|
t.assert.throws(() => {
|
|
db.aggregate('sum', {
|
|
start: 0,
|
|
step: (acc, value) => acc + value,
|
|
inverse: 10
|
|
});
|
|
}, {
|
|
code: 'ERR_INVALID_ARG_TYPE',
|
|
message: /The "options\.inverse" argument must be a function/,
|
|
});
|
|
});
|
|
|
|
test('throws if options.step.length is not an integer', (t) => {
|
|
for (const length of lengths) {
|
|
t.assert.throws(() => {
|
|
db.aggregate('sum', {
|
|
start: 0,
|
|
step: withLength((acc, value) => acc + value, length),
|
|
result: (total) => total
|
|
});
|
|
}, {
|
|
code: 'ERR_INVALID_ARG_TYPE',
|
|
message: /The "options\.step\.length" property must be an integer/,
|
|
}, `length=${String(length)}`);
|
|
}
|
|
});
|
|
|
|
test('throws if options.inverse.length is not an integer', (t) => {
|
|
for (const length of lengths) {
|
|
t.assert.throws(() => {
|
|
db.aggregate('sum', {
|
|
start: 0,
|
|
step: (acc, value) => acc + value,
|
|
inverse: withLength((acc, value) => acc - value, length),
|
|
result: (total) => total
|
|
});
|
|
}, {
|
|
code: 'ERR_INVALID_ARG_TYPE',
|
|
message: /The "options\.inverse\.length" property must be an integer/,
|
|
}, `length=${String(length)}`);
|
|
}
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('varargs', () => {
|
|
test('supports variable number of arguments when true', (t) => {
|
|
const db = new Database(':memory:');
|
|
t.after(() => db.close());
|
|
db.exec('CREATE TABLE data (value INTEGER)');
|
|
db.exec('INSERT INTO data VALUES (1), (2), (3)');
|
|
db.aggregate('sum_int', {
|
|
start: 0,
|
|
step: (_acc, _value, var1, var2, var3) => {
|
|
return var1 + var2 + var3;
|
|
},
|
|
varargs: true,
|
|
});
|
|
|
|
const result = db.prepare('SELECT sum_int(value, 1, 2, 3) as total FROM data').get();
|
|
|
|
t.assert.deepStrictEqual(result, { __proto__: null, total: 6 });
|
|
});
|
|
|
|
test('uses the max between step.length and inverse.length when false', (t) => {
|
|
const db = new Database(':memory:');
|
|
t.after(() => db.close());
|
|
db.exec(`
|
|
CREATE TABLE t3(x, y);
|
|
INSERT INTO t3 VALUES ('a', 1),
|
|
('b', 2),
|
|
('c', 3);
|
|
`);
|
|
|
|
db.aggregate('sumint', {
|
|
start: 0,
|
|
step: (acc, var1) => {
|
|
return var1 + acc;
|
|
},
|
|
inverse: (acc, var1, var2) => {
|
|
return acc - var1 - var2;
|
|
},
|
|
varargs: false,
|
|
});
|
|
|
|
const result = db.prepare(`
|
|
SELECT x, sumint(y, 10) OVER (
|
|
ORDER BY x ROWS BETWEEN 1 PRECEDING AND 1 FOLLOWING
|
|
) AS sum_y
|
|
FROM t3 ORDER BY x;
|
|
`).all();
|
|
|
|
t.assert.deepStrictEqual(result, [
|
|
{ __proto__: null, x: 'a', sum_y: 3 },
|
|
{ __proto__: null, x: 'b', sum_y: 6 },
|
|
{ __proto__: null, x: 'c', sum_y: -5 },
|
|
]);
|
|
|
|
t.assert.throws(() => {
|
|
db.prepare(`
|
|
SELECT x, sumint(y) OVER (
|
|
ORDER BY x ROWS BETWEEN 1 PRECEDING AND 1 FOLLOWING
|
|
) AS sum_y
|
|
FROM t3 ORDER BY x;
|
|
`);
|
|
}, {
|
|
code: 'ERR_SQLITE_ERROR',
|
|
message: 'wrong number of arguments to function sumint()'
|
|
});
|
|
});
|
|
|
|
test('throws if an incorrect number of arguments is provided when false', (t) => {
|
|
const db = new Database(':memory:');
|
|
t.after(() => db.close());
|
|
db.aggregate('sum_int', {
|
|
start: 0,
|
|
step: (_acc, var1, var2, var3) => {
|
|
return var1 + var2 + var3;
|
|
},
|
|
varargs: false,
|
|
});
|
|
|
|
t.assert.throws(() => {
|
|
db.prepare('SELECT sum_int(1, 2, 3, 4)').get();
|
|
}, {
|
|
code: 'ERR_SQLITE_ERROR',
|
|
message: 'wrong number of arguments to function sum_int()'
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('directOnly', () => {
|
|
test('is false by default', (t) => {
|
|
const db = new Database(':memory:');
|
|
t.after(() => db.close());
|
|
db.aggregate('func', {
|
|
start: 0,
|
|
step: (acc, value) => acc + value,
|
|
inverse: (acc, value) => acc - value,
|
|
});
|
|
db.exec(`
|
|
CREATE TABLE t3(x, y);
|
|
INSERT INTO t3 VALUES ('a', 4),
|
|
('b', 5),
|
|
('c', 3);
|
|
`);
|
|
|
|
db.exec(`
|
|
CREATE TRIGGER test_trigger
|
|
AFTER INSERT ON t3
|
|
BEGIN
|
|
SELECT func(1) OVER ();
|
|
END;
|
|
`);
|
|
|
|
// TRIGGER will work fine with the window function
|
|
db.exec('INSERT INTO t3 VALUES(\'d\', 6)');
|
|
});
|
|
|
|
test('set SQLITE_DIRECT_ONLY flag when true', (t) => {
|
|
const db = new Database(':memory:');
|
|
t.after(() => db.close());
|
|
db.aggregate('func', {
|
|
start: 0,
|
|
step: (acc, value) => acc + value,
|
|
inverse: (acc, value) => acc - value,
|
|
directOnly: true,
|
|
});
|
|
db.exec(`
|
|
CREATE TABLE t3(x, y);
|
|
INSERT INTO t3 VALUES ('a', 4),
|
|
('b', 5),
|
|
('c', 3);
|
|
`);
|
|
|
|
db.exec(`
|
|
CREATE TRIGGER test_trigger
|
|
AFTER INSERT ON t3
|
|
BEGIN
|
|
SELECT func(1) OVER ();
|
|
END;
|
|
`);
|
|
|
|
t.assert.throws(() => {
|
|
db.exec('INSERT INTO t3 VALUES(\'d\', 6)');
|
|
}, {
|
|
code: 'ERR_SQLITE_ERROR',
|
|
message: /unsafe use of func\(\)/
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('start', () => {
|
|
test('start option as a value', (t) => {
|
|
const db = new Database(':memory:');
|
|
t.after(() => db.close());
|
|
db.exec('CREATE TABLE data (value INTEGER)');
|
|
db.exec('INSERT INTO data VALUES (1), (2), (3)');
|
|
db.aggregate('sum_int', {
|
|
start: 0,
|
|
step: (acc, value) => acc + value,
|
|
});
|
|
|
|
const result = db.prepare('SELECT sum_int(value) as total FROM data').get();
|
|
|
|
t.assert.deepStrictEqual(result, { __proto__: null, total: 6 });
|
|
});
|
|
|
|
test('start option as a function', (t) => {
|
|
const db = new Database(':memory:');
|
|
t.after(() => db.close());
|
|
db.exec('CREATE TABLE data (value INTEGER)');
|
|
db.exec('INSERT INTO data VALUES (1), (2), (3)');
|
|
db.aggregate('sum_int', {
|
|
start: () => 0,
|
|
step: (acc, value) => acc + value,
|
|
});
|
|
|
|
const result = db.prepare('SELECT sum_int(value) as total FROM data').get();
|
|
|
|
t.assert.deepStrictEqual(result, { __proto__: null, total: 6 });
|
|
});
|
|
|
|
test('start option can hold any js value', (t) => {
|
|
const db = new Database(':memory:');
|
|
t.after(() => db.close());
|
|
db.exec('CREATE TABLE data (value INTEGER)');
|
|
db.exec('INSERT INTO data VALUES (1), (2), (3)');
|
|
db.aggregate('sum_int', {
|
|
start: () => [],
|
|
step: (acc, value) => {
|
|
return [...acc, value];
|
|
},
|
|
result: (acc) => acc.join(', '),
|
|
});
|
|
|
|
const result = db.prepare('SELECT sum_int(value) as total FROM data').get();
|
|
|
|
t.assert.deepStrictEqual(result, { __proto__: null, total: '1, 2, 3' });
|
|
});
|
|
|
|
test('throws if start throws an error', (t) => {
|
|
const db = new Database(':memory:');
|
|
t.after(() => db.close());
|
|
db.exec('CREATE TABLE data (value INTEGER)');
|
|
db.exec('INSERT INTO data VALUES (1), (2), (3)');
|
|
db.aggregate('agg', {
|
|
start: () => {
|
|
throw new Error('start error');
|
|
},
|
|
step: () => null,
|
|
});
|
|
|
|
t.assert.throws(() => {
|
|
db.prepare('SELECT agg()').get();
|
|
}, {
|
|
message: 'start error'
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('step', () => {
|
|
test('throws if step throws an error', (t) => {
|
|
const db = new Database(':memory:');
|
|
t.after(() => db.close());
|
|
db.exec('CREATE TABLE data (value INTEGER)');
|
|
db.exec('INSERT INTO data VALUES (1), (2), (3)');
|
|
db.aggregate('agg', {
|
|
start: 0,
|
|
step: () => {
|
|
throw new Error('step error');
|
|
},
|
|
});
|
|
|
|
t.assert.throws(() => {
|
|
db.prepare('SELECT agg()').get();
|
|
}, {
|
|
message: 'step error'
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('result', () => {
|
|
test('throws if result throws an error', (t) => {
|
|
const db = new Database(':memory:');
|
|
t.after(() => db.close());
|
|
db.exec('CREATE TABLE data (value INTEGER)');
|
|
db.exec('INSERT INTO data VALUES (1), (2), (3)');
|
|
db.aggregate('sum_int', {
|
|
start: 0,
|
|
step: (acc, value) => {
|
|
return acc + value;
|
|
},
|
|
result: () => {
|
|
throw new Error('result error');
|
|
},
|
|
});
|
|
t.assert.throws(() => {
|
|
db.prepare('SELECT sum_int(value) as result FROM data').get();
|
|
}, {
|
|
message: 'result error'
|
|
});
|
|
});
|
|
|
|
test('executes once when options.inverse is not present', (t) => {
|
|
const db = new Database(':memory:');
|
|
t.after(() => db.close());
|
|
const mockFn = t.mock.fn(() => 'overridden');
|
|
db.exec('CREATE TABLE data (value INTEGER)');
|
|
db.exec('INSERT INTO data VALUES (1), (2), (3)');
|
|
db.aggregate('sum_int', {
|
|
start: 0,
|
|
step: (acc, value) => {
|
|
return acc + value;
|
|
},
|
|
result: mockFn
|
|
});
|
|
|
|
const result = db.prepare('SELECT sum_int(value) as result FROM data').get();
|
|
|
|
t.assert.deepStrictEqual(result, { __proto__: null, result: 'overridden' });
|
|
t.assert.strictEqual(mockFn.mock.calls.length, 1);
|
|
t.assert.deepStrictEqual(mockFn.mock.calls[0].arguments, [6]);
|
|
});
|
|
|
|
test('executes once per row when options.inverse is present', (t) => {
|
|
const db = new Database(':memory:');
|
|
t.after(() => db.close());
|
|
const mockFn = t.mock.fn((acc) => acc);
|
|
db.exec(`
|
|
CREATE TABLE t3(x, y);
|
|
INSERT INTO t3 VALUES ('a', 4),
|
|
('b', 5),
|
|
('c', 3);
|
|
`);
|
|
db.aggregate('sumint', {
|
|
start: 0,
|
|
step: (acc, value) => {
|
|
return acc + value;
|
|
},
|
|
inverse: (acc, value) => {
|
|
return acc - value;
|
|
},
|
|
result: mockFn
|
|
});
|
|
|
|
db.prepare(`
|
|
SELECT x, sumint(y) OVER (
|
|
ORDER BY x ROWS BETWEEN 1 PRECEDING AND 1 FOLLOWING
|
|
) AS sum_y
|
|
FROM t3 ORDER BY x;
|
|
`).all();
|
|
|
|
t.assert.strictEqual(mockFn.mock.calls.length, 3);
|
|
t.assert.deepStrictEqual(mockFn.mock.calls[0].arguments, [9]);
|
|
t.assert.deepStrictEqual(mockFn.mock.calls[1].arguments, [12]);
|
|
t.assert.deepStrictEqual(mockFn.mock.calls[2].arguments, [8]);
|
|
});
|
|
});
|
|
|
|
test('throws an error when trying to use as windown function but didn\'t provide options.inverse', (t) => {
|
|
const db = new Database(':memory:');
|
|
t.after(() => db.close());
|
|
db.exec(`
|
|
CREATE TABLE t3(x, y);
|
|
INSERT INTO t3 VALUES ('a', 4),
|
|
('b', 5),
|
|
('c', 3);
|
|
`);
|
|
|
|
db.aggregate('sumint', {
|
|
start: 0,
|
|
step: (total, nextValue) => total + nextValue,
|
|
});
|
|
|
|
t.assert.throws(() => {
|
|
db.prepare(`
|
|
SELECT x, sumint(y) OVER (
|
|
ORDER BY x ROWS BETWEEN 1 PRECEDING AND 1 FOLLOWING
|
|
) AS sum_y
|
|
FROM t3 ORDER BY x;
|
|
`);
|
|
}, {
|
|
code: 'ERR_SQLITE_ERROR',
|
|
message: 'sumint() may not be used as a window function'
|
|
});
|
|
});
|