current commit
261009.2 · sqlite: validate aggregate() name and options
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'
  });
});