current commit
261029.4 · sqlite: cache the SQLTagStore constructor template
'use strict';
// Flags: --expose-gc

const { skipIfSQLiteMissing } = require('../common');
skipIfSQLiteMissing();

const assert = require('assert');
const { Database } = require('node:sqlite');
const { test, beforeEach } = require('node:test');

const db = new Database(':memory:');
const sql = db.createTagStore(10);

beforeEach(() => {
  db.exec('DROP TABLE IF EXISTS foo');
  db.exec('CREATE TABLE foo (id INTEGER PRIMARY KEY, text TEXT)');
  sql.clear();
});

test('throws error if database is not open', () => {
  const db = new Database(':memory:', { open: false });

  assert.throws(() => {
    db.createTagStore(10);
  }, {
    code: 'ERR_INVALID_STATE',
    message: 'database is not open'
  });
});

test('sql.run inserts data', () => {
  assert.strictEqual(sql.run`INSERT INTO foo (text) VALUES (${'bob'})`.changes, 1);
  assert.strictEqual(sql.run`INSERT INTO foo (text) VALUES (${'mac'})`.changes, 1);
  assert.strictEqual(sql.run`INSERT INTO foo (text) VALUES (${'alice'})`.changes, 1);

  const count = db.prepare('SELECT COUNT(*) as count FROM foo').get().count;
  assert.strictEqual(count, 3);
});

test('sql.get retrieves a single row', () => {
  assert.strictEqual(sql.run`INSERT INTO foo (text) VALUES (${'bob'})`.changes, 1);
  const first = sql.get`SELECT * FROM foo ORDER BY id ASC`;
  assert.ok(first);
  assert.strictEqual(first.text, 'bob');
  assert.strictEqual(first.id, 1);
  assert.strictEqual(Object.getPrototypeOf(first), null);
});

test('sql.all retrieves all rows', () => {
  assert.strictEqual(sql.run`INSERT INTO foo (text) VALUES (${'bob'})`.changes, 1);
  assert.strictEqual(sql.run`INSERT INTO foo (text) VALUES (${'mac'})`.changes, 1);
  assert.strictEqual(sql.run`INSERT INTO foo (text) VALUES (${'alice'})`.changes, 1);

  const all = sql.all`SELECT * FROM foo ORDER BY id ASC`;
  assert.strictEqual(Array.isArray(all), true);
  assert.strictEqual(all.length, 3);
  for (const row of all) {
    assert.strictEqual(Object.getPrototypeOf(row), null);
  }
  assert.deepStrictEqual(all.map((r) => r.text), ['bob', 'mac', 'alice']);
});

test('sql.iterate retrieves rows via iterator', () => {
  assert.strictEqual(sql.run`INSERT INTO foo (text) VALUES (${'bob'})`.changes, 1);
  assert.strictEqual(sql.run`INSERT INTO foo (text) VALUES (${'mac'})`.changes, 1);
  assert.strictEqual(sql.run`INSERT INTO foo (text) VALUES (${'alice'})`.changes, 1);

  const iter = sql.iterate`SELECT * FROM foo ORDER BY id ASC`;
  const iterRows = [];
  for (const row of iter) {
    assert.ok(row);
    assert.strictEqual(Object.getPrototypeOf(row), null);
    iterRows.push(row.text);
  }
  assert.deepStrictEqual(iterRows, ['bob', 'mac', 'alice']);
});

test('queries with no results', () => {
  const none = sql.get`SELECT * FROM foo WHERE text = ${'notfound'}`;
  assert.strictEqual(none, undefined);

  const empty = sql.all`SELECT * FROM foo WHERE text = ${'notfound'}`;
  assert.deepStrictEqual(empty, []);

  let count = 0;
  // eslint-disable-next-line no-unused-vars
  for (const _ of sql.iterate`SELECT * FROM foo WHERE text = ${'notfound'}`) {
    count++;
  }
  assert.strictEqual(count, 0);
});

test('rejects parameters outside of template expressions', () => {
  const ldb = new Database(':memory:');
  const lsql = ldb.createTagStore();
  ldb.exec(`
    CREATE TABLE secrets(owner TEXT, token TEXT);
    INSERT INTO secrets VALUES ('victim', 'secret');
    CREATE TABLE transfers(from_user TEXT, amount INTEGER);
  `);

  const expectedError = {
    code: 'ERR_INVALID_ARG_VALUE',
    message: /must be bound using template literal placeholders/,
  };

  for (const method of ['get', 'all', 'iterate']) {
    // Prime the cached statement with a bound value before each attempt.
    // eslint-disable-next-line no-unused-expressions
    lsql.all`SELECT token FROM secrets WHERE owner = ${'victim'}`;
    assert.throws(() => {
      // eslint-disable-next-line no-unused-expressions
      lsql[method]`SELECT token FROM secrets WHERE owner = ?`;
    }, expectedError);
  }

  // eslint-disable-next-line no-unused-expressions
  lsql.run`INSERT INTO transfers VALUES (${'victim'},${100})`;
  assert.throws(() => {
    // eslint-disable-next-line no-unused-expressions
    lsql.run`INSERT INTO transfers VALUES (?,?)`;
  }, expectedError);
  assert.strictEqual(
    ldb.prepare('SELECT COUNT(*) AS count FROM transfers').get().count,
    1);

  ldb.close();
});

test('TagStore capacity, size, and clear', () => {
  assert.strictEqual(sql.capacity, 10);
  assert.strictEqual(sql.size, 0);

  assert.strictEqual(sql.run`INSERT INTO foo (text) VALUES (${'one'})`.changes, 1);
  assert.strictEqual(sql.size, 1);

  assert.ok(sql.get`SELECT * FROM foo WHERE text = ${'one'}`);
  assert.strictEqual(sql.size, 2);

  // Using the same template string shouldn't increase the size
  assert.strictEqual(sql.get`SELECT * FROM foo WHERE text = ${'two'}`, undefined);
  assert.strictEqual(sql.size, 2);

  assert.strictEqual(sql.all`SELECT * FROM foo`.length, 1);
  assert.strictEqual(sql.size, 3);

  sql.clear();
  assert.strictEqual(sql.size, 0);
  assert.strictEqual(sql.capacity, 10);
});

test('iterator is invalidated when the cached statement is reset', () => {
  const ldb = new Database(':memory:');
  const lsql = ldb.createTagStore();
  ldb.exec('CREATE TABLE foo (id INTEGER PRIMARY KEY, text TEXT)');
  for (let i = 0; i < 5; i++) {
    // eslint-disable-next-line no-unused-expressions
    lsql.run`INSERT INTO foo (text) VALUES (${String(i)})`;
  }

  // Invalidated by sql.get on the same tagged literal
  let it = lsql.iterate`SELECT * FROM foo ORDER BY id ASC`;
  it.next();
  // eslint-disable-next-line no-unused-expressions
  lsql.get`SELECT * FROM foo ORDER BY id ASC`;
  assert.throws(() => { it.next(); }, {
    code: 'ERR_INVALID_STATE',
    message: /iterator was invalidated/,
  });

  // Invalidated by sql.all on the same tagged literal
  it = lsql.iterate`SELECT * FROM foo ORDER BY id ASC`;
  it.next();
  // eslint-disable-next-line no-unused-expressions
  lsql.all`SELECT * FROM foo ORDER BY id ASC`;
  assert.throws(() => { it.next(); }, {
    code: 'ERR_INVALID_STATE',
    message: /iterator was invalidated/,
  });

  // Invalidated by sql.run on the same tagged literal
  it = lsql.iterate`SELECT * FROM foo ORDER BY id ASC`;
  it.next();
  // eslint-disable-next-line no-unused-expressions
  lsql.run`SELECT * FROM foo ORDER BY id ASC`;
  assert.throws(() => { it.next(); }, {
    code: 'ERR_INVALID_STATE',
    message: /iterator was invalidated/,
  });

  // Invalidated by a new sql.iterate on the same tagged literal
  it = lsql.iterate`SELECT * FROM foo ORDER BY id ASC`;
  it.next();
  const it2 = lsql.iterate`SELECT * FROM foo ORDER BY id ASC`;
  assert.throws(() => { it.next(); }, {
    code: 'ERR_INVALID_STATE',
    message: /iterator was invalidated/,
  });

  // The fresh iterator still works.
  assert.strictEqual(it2.next().done, false);

  ldb.close();
});

test('a stale iterator cannot replay a victim-bound write', () => {
  const bank = new Database(':memory:');
  const tx = bank.createTagStore();
  bank.exec(`
    CREATE TABLE acct(user TEXT PRIMARY KEY, balance INTEGER);
    CREATE TABLE transfers(id INTEGER PRIMARY KEY AUTOINCREMENT,
      from_user TEXT, to_user TEXT, amount INTEGER);
    CREATE TRIGGER debit AFTER INSERT ON transfers
      BEGIN
        UPDATE acct SET balance = balance - NEW.amount WHERE user = NEW.from_user;
        UPDATE acct SET balance = balance + NEW.amount WHERE user = NEW.to_user;
      END;
    INSERT INTO acct VALUES ('victim',   1000);
    INSERT INTO acct VALUES ('attacker',    0);
  `);

  // Attacker arms an iterator without stepping it. The static parts of this
  // tagged literal must be byte-identical to the victim's below so they share
  // the same cached statement.
  const armed = tx.iterate`INSERT INTO transfers(from_user, to_user, amount) VALUES (${'attacker'}, ${'attacker'}, ${0}) RETURNING id`;

  // Victim makes one legitimate transfer through the same tagged literal.
  // eslint-disable-next-line no-unused-expressions
  tx.get`INSERT INTO transfers(from_user, to_user, amount) VALUES (${'victim'}, ${'attacker'}, ${100}) RETURNING id`;

  // The stale iterator must not be able to replay the victim's write.
  assert.throws(() => { armed.next(); }, {
    code: 'ERR_INVALID_STATE',
    message: /iterator was invalidated/,
  });

  const balances = bank.prepare('SELECT user, balance FROM acct ORDER BY user').all();
  assert.deepStrictEqual(balances, [
    { __proto__: null, user: 'attacker', balance: 100 },
    { __proto__: null, user: 'victim', balance: 900 },
  ]);
  const count = bank.prepare('SELECT COUNT(*) AS c FROM transfers').get().c;
  assert.strictEqual(count, 1);

  bank.close();
});

test('a finished iterator stays done and does not restart', () => {
  // eslint-disable-next-line no-unused-expressions
  sql.run`INSERT INTO foo (text) VALUES (${'bob'})`;

  const iter = sql.iterate`SELECT * FROM foo ORDER BY id ASC`;
  assert.strictEqual(iter.next().done, false);
  assert.strictEqual(iter.next().done, true);
  // A subsequent next() must not restart the statement.
  assert.strictEqual(iter.next().done, true);
  assert.strictEqual(iter.next().done, true);
});

test('createTagStore throws on invalid maxSize', () => {
  const db = new Database(':memory:');

  assert.throws(() => db.createTagStore(0), {
    code: 'ERR_OUT_OF_RANGE',
    message: /maxSize/,
  });

  assert.throws(() => db.createTagStore(-1), {
    code: 'ERR_OUT_OF_RANGE',
    message: /maxSize/,
  });

  assert.throws(() => db.createTagStore(NaN), {
    code: 'ERR_OUT_OF_RANGE',
    message: /maxSize/,
  });

  assert.throws(() => db.createTagStore(1.5), {
    code: 'ERR_OUT_OF_RANGE',
    message: /maxSize/,
  });

  assert.throws(() => db.createTagStore('abc'), {
    code: 'ERR_INVALID_ARG_TYPE',
    message: /maxSize/,
  });

  assert.throws(() => db.createTagStore(Number.MAX_SAFE_INTEGER), {
    code: 'ERR_OUT_OF_RANGE',
    message: /maxSize/,
  });
});

test('sql.db returns the associated Database instance', () => {
  assert.strictEqual(sql.db, db);
});

test('tag stores share a prototype', () => {
  const other = new Database(':memory:').createTagStore();
  assert.strictEqual(Object.getPrototypeOf(other), Object.getPrototypeOf(sql));
  assert.strictEqual(other.constructor, sql.constructor);
});

test('sql error messages are descriptive', () => {
  assert.strictEqual(sql.run`INSERT INTO foo (text) VALUES (${'test'})`.changes, 1);

  // Test with non-existent column
  assert.throws(() => {
    const result = sql.get`SELECT nonexistent_column FROM foo`;
    assert.fail(`Expected error, got: ${JSON.stringify(result)}`);
  }, {
    code: 'ERR_SQLITE_ERROR',
    message: /no such column/i,
  });

  // Test with non-existent table
  assert.throws(() => {
    const result = sql.get`SELECT * FROM nonexistent_table`;
    assert.fail(`Expected error, got: ${JSON.stringify(result)}`);
  }, {
    code: 'ERR_SQLITE_ERROR',
    message: /no such table/i,
  });
});

test('rejects SQL that contains no statements', () => {
  const expectedError = {
    code: 'ERR_INVALID_ARG_VALUE',
    message: /contains no statements/,
  };

  for (const method of ['run', 'get', 'all', 'iterate']) {
    assert.throws(() => {
      // eslint-disable-next-line no-unused-expressions
      sql[method]`-- comment`;
    }, expectedError);

    assert.throws(() => {
      // eslint-disable-next-line no-unused-expressions
      sql[method]``;
    }, expectedError);
  }

  // A rejected statement must not be cached, so a later valid query with the
  // same tag store still works.
  assert.strictEqual(sql.run`INSERT INTO foo (text) VALUES (${'bob'})`.changes, 1);
});

test('a tag store keeps the database alive by itself', () => {
  const sql = new Database(':memory:').createTagStore();

  sql.db.exec('CREATE TABLE test (data INTEGER)');

  global.gc();

  // eslint-disable-next-line no-unused-expressions
  sql.run`INSERT INTO test (data) VALUES (1)`;
});

test('tag store prevents circular reference leaks', async () => {
  const { gcUntil } = require('../common/gc');

  const before = process.memoryUsage().heapUsed;

  // Create many SQLTagStore + Database pairs with circular references
  for (let i = 0; i < 1000; i++) {
    const sql = new Database(':memory:').createTagStore();
    sql.db.exec('CREATE TABLE test (data INTEGER)');
    // eslint-disable-next-line no-void
    sql.db.setAuthorizer(() => void sql.db);
  }

  // GC until memory stabilizes or give up after 20 attempts
  await gcUntil('tag store leak check', () => {
    const after = process.memoryUsage().heapUsed;
    // Memory should not grow significantly (allow 50% margin for noise)
    return after < before * 1.5;
  }, 20);
});

test('cached statements are finalized when the database is closed', () => {
  const db = new Database(':memory:');
  const sql = db.createTagStore();

  db.exec('CREATE TABLE foo (id INTEGER PRIMARY KEY)');
  db.exec('INSERT INTO foo (id) VALUES (1)');
  assert.deepStrictEqual(sql.all`SELECT id FROM foo`, [{ __proto__: null, id: 1 }]);

  db.close();
  db.open();

  assert.throws(() => sql.all`SELECT id FROM foo`, {
    code: 'ERR_SQLITE_ERROR',
    message: /no such table/i,
  });
});