current commit
261022.11 · sqlite: propagate errors from Database option getters
'use strict';
const { skipIfSQLiteMissing } = require('../common');
skipIfSQLiteMissing();
const tmpdir = require('../common/tmpdir');
const { existsSync, mkdirSync } = require('node:fs');
const { join } = require('node:path');
const { Database, Statement } = require('node:sqlite');
const { suite, test } = require('node:test');
let cnt = 0;

tmpdir.refresh();

function nextDb() {
  return join(tmpdir.path, `database-${cnt++}.db`);
}

suite('Database() constructor', () => {
  test('throws if called without new', (t) => {
    t.assert.throws(() => {
      Database();
    }, {
      code: 'ERR_CONSTRUCT_CALL_REQUIRED',
      message: /Cannot call constructor without `new`/,
    });
  });

  test('throws if database path is not a string, Uint8Array, or URL', (t) => {
    t.assert.throws(() => {
      new Database();
    }, {
      code: 'ERR_INVALID_ARG_TYPE',
      message: /The "path" argument must be a string, Uint8Array, or URL without null bytes/,
    });
  });

  test('throws if the database location as Buffer contains null bytes', (t) => {
    t.assert.throws(() => {
      new Database(Buffer.from('l\0cation'));
    }, {
      code: 'ERR_INVALID_ARG_TYPE',
      message: 'The "path" argument must be a string, Uint8Array, or URL without null bytes.',
    });
  });

  test('throws if the database location as string contains null bytes', (t) => {
    t.assert.throws(() => {
      new Database('l\0cation');
    }, {
      code: 'ERR_INVALID_ARG_TYPE',
      message: 'The "path" argument must be a string, Uint8Array, or URL without null bytes.',
    });
  });

  test('throws if the URL-like path has an unparsable href', (t) => {
    t.assert.throws(() => {
      new Database({ href: 'not a url' });
    }, { code: 'ERR_INVALID_URL' });
  });

  test('throws if options is provided but is not an object', (t) => {
    t.assert.throws(() => {
      new Database('foo', null);
    }, {
      code: 'ERR_INVALID_ARG_TYPE',
      message: /The "options" argument must be an object/,
    });
  });

  test('throws if options.open is provided but is not a boolean', (t) => {
    t.assert.throws(() => {
      new Database('foo', { open: 5 });
    }, {
      code: 'ERR_INVALID_ARG_TYPE',
      message: /The "options\.open" argument must be a boolean/,
    });
  });

  test('throws if options.readOnly is provided but is not a boolean', (t) => {
    t.assert.throws(() => {
      new Database('foo', { readOnly: 5 });
    }, {
      code: 'ERR_INVALID_ARG_TYPE',
      message: /The "options\.readOnly" argument must be a boolean/,
    });
  });

  test('throws if options.timeout is provided but is not an integer', (t) => {
    t.assert.throws(() => {
      new Database('foo', { timeout: .99 });
    }, {
      code: 'ERR_INVALID_ARG_TYPE',
      message: /The "options\.timeout" argument must be an integer/,
    });
  });

  test('is not read-only by default', (t) => {
    const dbPath = nextDb();
    using db = new Database(dbPath);
    db.exec('CREATE TABLE foo (id INTEGER PRIMARY KEY)');
  });

  test('is read-only if readOnly is set', (t) => {
    const dbPath = nextDb();
    {
      using db = new Database(dbPath);
      db.exec('CREATE TABLE foo (id INTEGER PRIMARY KEY)');
    }
    {
      using db = new Database(dbPath, { readOnly: true });
      t.assert.throws(() => {
        db.exec('CREATE TABLE bar (id INTEGER PRIMARY KEY)');
      }, {
        code: 'ERR_SQLITE_ERROR',
        message: /attempt to write a readonly database/,
      });
    }
  });

  test('throws if options.enableForeignKeyConstraints is provided but is not a boolean', (t) => {
    t.assert.throws(() => {
      new Database('foo', { enableForeignKeyConstraints: 5 });
    }, {
      code: 'ERR_INVALID_ARG_TYPE',
      message: /The "options\.enableForeignKeyConstraints" argument must be a boolean/,
    });
  });

  test('enables foreign key constraints by default', (t) => {
    const dbPath = nextDb();
    using db = new Database(dbPath);
    db.exec(`
      CREATE TABLE foo (id INTEGER PRIMARY KEY);
      CREATE TABLE bar (foo_id INTEGER REFERENCES foo(id));
    `);
    t.assert.throws(() => {
      db.exec('INSERT INTO bar (foo_id) VALUES (1)');
    }, {
      code: 'ERR_SQLITE_ERROR',
      message: 'FOREIGN KEY constraint failed',
    });
  });

  test('allows disabling foreign key constraints', (t) => {
    const dbPath = nextDb();
    using db = new Database(dbPath, { enableForeignKeyConstraints: false });
    db.exec(`
      CREATE TABLE foo (id INTEGER PRIMARY KEY);
      CREATE TABLE bar (foo_id INTEGER REFERENCES foo(id));
    `);
    db.exec('INSERT INTO bar (foo_id) VALUES (1)');
  });

  test('throws if options.enableDoubleQuotedStringLiterals is provided but is not a boolean', (t) => {
    t.assert.throws(() => {
      new Database('foo', { enableDoubleQuotedStringLiterals: 5 });
    }, {
      code: 'ERR_INVALID_ARG_TYPE',
      message: /The "options\.enableDoubleQuotedStringLiterals" argument must be a boolean/,
    });
  });

  test('disables double-quoted string literals by default', (t) => {
    const dbPath = nextDb();
    using db = new Database(dbPath);
    t.assert.throws(() => {
      db.exec('SELECT "foo";');
    }, {
      code: 'ERR_SQLITE_ERROR',
      message: /no such column: "?foo"?/,
    });
  });

  test('allows enabling double-quoted string literals', (t) => {
    const dbPath = nextDb();
    using db = new Database(dbPath, { enableDoubleQuotedStringLiterals: true });
    db.exec('SELECT "foo";');
  });

  test('throws if options.readBigInts is provided but is not a boolean', (t) => {
    t.assert.throws(() => {
      new Database('foo', { readBigInts: 42 });
    }, {
      code: 'ERR_INVALID_ARG_TYPE',
      message: 'The "options.readBigInts" argument must be a boolean.',
    });
  });

  test('allows reading big integers', (t) => {
    const dbPath = nextDb();
    using db = new Database(dbPath, { readBigInts: true });

    const setup = db.exec(`
      CREATE TABLE data(key INTEGER PRIMARY KEY, val INTEGER) STRICT;
      INSERT INTO data (key, val) VALUES (1, 42);
    `);
    t.assert.strictEqual(setup, undefined);

    const query = db.prepare('SELECT val FROM data');
    t.assert.deepStrictEqual(query.get(), { __proto__: null, val: 42n });

    const insert = db.prepare('INSERT INTO data (key) VALUES (?)');
    t.assert.deepStrictEqual(
      insert.run(20),
      { changes: 1n, lastInsertRowid: 20n },
    );
  });

  test('throws if options.returnArrays is provided but is not a boolean', (t) => {
    t.assert.throws(() => {
      new Database('foo', { returnArrays: 42 });
    }, {
      code: 'ERR_INVALID_ARG_TYPE',
      message: 'The "options.returnArrays" argument must be a boolean.',
    });
  });

  test('allows returning arrays', (t) => {
    const dbPath = nextDb();
    using db = new Database(dbPath, { returnArrays: true });
    const setup = db.exec(`
      CREATE TABLE data(key INTEGER PRIMARY KEY, val TEXT) STRICT;
      INSERT INTO data (key, val) VALUES (1, 'one');
      INSERT INTO data (key, val) VALUES (2, 'two');
    `);
    t.assert.strictEqual(setup, undefined);

    const query = db.prepare('SELECT key, val FROM data WHERE key = 1');
    t.assert.deepStrictEqual(query.get(), [1, 'one']);
  });

  test('throws if options.allowBareNamedParameters is provided but is not a boolean', (t) => {
    t.assert.throws(() => {
      new Database('foo', { allowBareNamedParameters: 42 });
    }, {
      code: 'ERR_INVALID_ARG_TYPE',
      message: 'The "options.allowBareNamedParameters" argument must be a boolean.',
    });
  });

  test('throws if bare named parameters are used when option is false', (t) => {
    const dbPath = nextDb();
    using db = new Database(dbPath, { allowBareNamedParameters: false });
    const setup = db.exec(
      'CREATE TABLE data(key INTEGER PRIMARY KEY, val INTEGER) STRICT;'
    );
    t.assert.strictEqual(setup, undefined);

    const stmt = db.prepare('INSERT INTO data (key, val) VALUES ($k, $v)');
    t.assert.throws(() => {
      stmt.run({ k: 2, v: 4 });
    }, {
      code: 'ERR_INVALID_STATE',
      message: /Unknown named parameter 'k'/,
    });
  });

  test('throws if options.allowUnknownNamedParameters is provided but is not a boolean', (t) => {
    t.assert.throws(() => {
      new Database('foo', { allowUnknownNamedParameters: 42 });
    }, {
      code: 'ERR_INVALID_ARG_TYPE',
      message: 'The "options.allowUnknownNamedParameters" argument must be a boolean.',
    });
  });

  test('allows unknown named parameters', (t) => {
    const dbPath = nextDb();
    using db = new Database(dbPath, { allowUnknownNamedParameters: true });
    const setup = db.exec(
      'CREATE TABLE data(key INTEGER, val INTEGER) STRICT;'
    );
    t.assert.strictEqual(setup, undefined);

    const stmt = db.prepare('INSERT INTO data (key, val) VALUES ($k, $v)');
    const params = { $a: 1, $b: 2, $k: 42, $y: 25, $v: 84, $z: 99 };
    t.assert.deepStrictEqual(
      stmt.run(params),
      { changes: 1, lastInsertRowid: 1 },
    );
  });

  for (const option of [
    'readBigInts',
    'returnArrays',
    'allowBareNamedParameters',
    'allowUnknownNamedParameters',
  ]) {
    test(`propagates an error thrown by the options.${option} getter`, (t) => {
      const dbPath = nextDb();
      const error = new Error('boom');
      t.assert.throws(() => {
        new Database(dbPath, {
          get [option]() { throw error; },
        });
      }, error);
      t.assert.strictEqual(existsSync(dbPath), false);
    });
  }

  test('has sqlite-type symbol property', (t) => {
    const dbPath = nextDb();
    using db = new Database(dbPath);

    const sqliteTypeSymbol = Symbol.for('sqlite-type');
    t.assert.strictEqual(db[sqliteTypeSymbol], 'node:sqlite');
  });
});

suite('Database.prototype.open()', () => {
  test('opens a database connection', (t) => {
    const dbPath = nextDb();
    using db = new Database(dbPath, { open: false });

    t.assert.strictEqual(db.isOpen, false);
    t.assert.strictEqual(existsSync(dbPath), false);
    t.assert.strictEqual(db.open(), undefined);
    t.assert.strictEqual(db.isOpen, true);
    t.assert.strictEqual(existsSync(dbPath), true);
  });

  test('throws if database is already open', (t) => {
    using db = new Database(nextDb(), { open: false });

    t.assert.strictEqual(db.isOpen, false);
    db.open();
    t.assert.strictEqual(db.isOpen, true);
    t.assert.throws(() => {
      db.open();
    }, {
      code: 'ERR_INVALID_STATE',
      message: /database is already open/,
    });
    t.assert.strictEqual(db.isOpen, true);
  });

  test('does not leave the database open after a failed open', (t) => {
    // Regression test for https://github.com/nodejs/node/issues/63831
    const dbDir = join(tmpdir.path, `database-dir-${cnt++}`);
    const dbPath = join(dbDir, 'failed-open.db');
    using db = new Database(dbPath, { open: false });

    // The directory does not exist, so opening the database fails.
    t.assert.throws(() => {
      db.open();
    }, {
      code: 'ERR_SQLITE_ERROR',
      message: /unable to open database file/,
    });
    t.assert.strictEqual(db.isOpen, false);

    // The connection must not be usable after a failed open.
    t.assert.throws(() => {
      db.exec('SELECT 1');
    }, {
      code: 'ERR_INVALID_STATE',
      message: /database is not open/,
    });
    t.assert.throws(() => {
      db.function('fn', () => {});
    }, {
      code: 'ERR_INVALID_STATE',
      message: /database is not open/,
    });

    // The database can be opened once the underlying problem is resolved.
    mkdirSync(dbDir);
    t.assert.strictEqual(db.open(), undefined);
    t.assert.strictEqual(db.isOpen, true);
    db.exec('CREATE TABLE foo (id INTEGER PRIMARY KEY)');
  });
});

suite('Database.prototype.close()', () => {
  test('closes an open database connection', (t) => {
    using db = new Database(nextDb());

    t.assert.strictEqual(db.isOpen, true);
    t.assert.strictEqual(db.close(), undefined);
    t.assert.strictEqual(db.isOpen, false);
  });

  test('throws if database is not open', (t) => {
    using db = new Database(nextDb(), { open: false });

    t.assert.strictEqual(db.isOpen, false);
    t.assert.throws(() => {
      db.close();
    }, {
      code: 'ERR_INVALID_STATE',
      message: /database is not open/,
    });
    t.assert.strictEqual(db.isOpen, false);
  });
});

suite('Database.prototype.prepare()', () => {
  test('returns a prepared statement', (t) => {
    using db = new Database(nextDb());
    const stmt = db.prepare('CREATE TABLE webstorage(key TEXT)');
    t.assert.ok(stmt instanceof Statement);
  });

  test('throws if database is not open', (t) => {
    using db = new Database(nextDb(), { open: false });

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

  test('throws if sql is not a string', (t) => {
    using db = new Database(nextDb());

    t.assert.throws(() => {
      db.prepare();
    }, {
      code: 'ERR_INVALID_ARG_TYPE',
      message: /The "sql" argument must be a string/,
    });
  });

  test('throws if sql contains no statements', (t) => {
    using db = new Database(nextDb());

    for (const sql of ['', '   ', ';', '-- comment', '/* comment */']) {
      t.assert.throws(() => {
        db.prepare(sql);
      }, {
        code: 'ERR_INVALID_ARG_VALUE',
        message: /contains no statements/,
      });
    }
  });

  test('prepares statements that contain comments', (t) => {
    using db = new Database(nextDb());
    const queries = [
      '-- lead\nSELECT 1 AS v',
      'SELECT 1 AS v -- trail',
      'SELECT /* mid */ 1 AS v',
    ];

    for (const sql of queries) {
      t.assert.strictEqual(db.prepare(sql).get().v, 1);
    }
  });
});

suite('Database.prototype.exec()', () => {
  test('executes SQL', (t) => {
    using db = new Database(nextDb());
    const result = db.exec(`
      CREATE TABLE data(
        key INTEGER PRIMARY KEY,
        val INTEGER
      ) STRICT;
      INSERT INTO data (key, val) VALUES (1, 2);
      INSERT INTO data (key, val) VALUES (8, 9);
    `);
    t.assert.strictEqual(result, undefined);
    const stmt = db.prepare('SELECT * FROM data ORDER BY key');
    t.assert.deepStrictEqual(stmt.all(), [
      { __proto__: null, key: 1, val: 2 },
      { __proto__: null, key: 8, val: 9 },
    ]);
  });

  test('reports errors from SQLite', (t) => {
    using db = new Database(nextDb());

    t.assert.throws(() => {
      db.exec('CREATE TABLEEEE');
    }, {
      code: 'ERR_SQLITE_ERROR',
      message: /syntax error/,
    });
  });

  test('throws if the URL does not have the file: scheme', (t) => {
    t.assert.throws(() => {
      new Database(new URL('http://example.com'));
    }, {
      code: 'ERR_INVALID_URL_SCHEME',
      message: 'The URL must be of scheme file:',
    });
  });

  test('throws if database is not open', (t) => {
    using db = new Database(nextDb(), { open: false });

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

  test('throws if sql is not a string', (t) => {
    using db = new Database(nextDb());

    t.assert.throws(() => {
      db.exec();
    }, {
      code: 'ERR_INVALID_ARG_TYPE',
      message: /The "sql" argument must be a string/,
    });
  });
});

suite('Database.prototype.isTransaction', () => {
  test('correctly detects a committed transaction', (t) => {
    using db = new Database(':memory:');

    t.assert.strictEqual(db.isTransaction, false);
    db.exec('BEGIN');
    t.assert.strictEqual(db.isTransaction, true);
    db.exec('CREATE TABLE foo (id INTEGER PRIMARY KEY)');
    t.assert.strictEqual(db.isTransaction, true);
    db.exec('COMMIT');
    t.assert.strictEqual(db.isTransaction, false);
  });

  test('correctly detects a rolled back transaction', (t) => {
    using db = new Database(':memory:');

    t.assert.strictEqual(db.isTransaction, false);
    db.exec('BEGIN');
    t.assert.strictEqual(db.isTransaction, true);
    db.exec('CREATE TABLE foo (id INTEGER PRIMARY KEY)');
    t.assert.strictEqual(db.isTransaction, true);
    db.exec('ROLLBACK');
    t.assert.strictEqual(db.isTransaction, false);
  });

  test('throws if database is not open', (t) => {
    using db = new Database(nextDb(), { open: false });

    t.assert.throws(() => {
      return db.isTransaction;
    }, {
      code: 'ERR_INVALID_STATE',
      message: /database is not open/,
    });
  });
});

suite('Database.prototype.location()', () => {
  test('throws if database is not open', (t) => {
    using db = new Database(nextDb(), { open: false });

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

  test('throws if provided dbName is not string', (t) => {
    using db = new Database(nextDb());

    t.assert.throws(() => {
      db.location(null);
    }, {
      code: 'ERR_INVALID_ARG_TYPE',
      message: /The "dbName" argument must be a string/,
    });
  });

  test('returns null when connected to in-memory database', (t) => {
    using db = new Database(':memory:');
    t.assert.strictEqual(db.location(), null);
  });

  test('returns db path when connected to a persistent database', (t) => {
    const dbPath = nextDb();
    using db = new Database(dbPath);
    t.assert.strictEqual(db.location(), dbPath);
  });

  test('returns that specific db path when attached', (t) => {
    const dbPath = nextDb();
    const otherPath = nextDb();
    using db = new Database(dbPath);

    // Adding this escape because the test with unusual chars have a single quote which breaks the query
    const escapedPath = otherPath.replace("'", "''");
    db.exec(`ATTACH DATABASE '${escapedPath}' AS other`);

    t.assert.strictEqual(db.location('other'), otherPath);
  });
});

suite('Database.prototype[Symbol.dispose]', () => {
  test('closes an open database', (t) => {
    const db = new Database(nextDb());
    t.assert.strictEqual(db.isOpen, true);
    db[Symbol.dispose]();
    t.assert.strictEqual(db.isOpen, false);
  });

  test('does not throw on databases that are not open', (t) => {
    const db = new Database(nextDb(), { open: false });
    t.assert.strictEqual(db.isOpen, false);
    db[Symbol.dispose]();
  });
});