diff --git a/lib/darwin-arm64.js b/lib/darwin-arm64.js index a2662d4c8..845d20917 100644 --- a/lib/darwin-arm64.js +++ b/lib/darwin-arm64.js @@ -1,3 +1,3 @@ 'use strict'; -module.exports = require('./database')(() => require('../prebuilds/darwin-arm64.node'), false); +module.exports = require('./with-binding')(() => require('../prebuilds/darwin-arm64.node'), false); module.exports.SqliteError = require('./sqlite-error'); diff --git a/lib/darwin-x64.js b/lib/darwin-x64.js index 8385b3e62..64bd5a866 100644 --- a/lib/darwin-x64.js +++ b/lib/darwin-x64.js @@ -1,3 +1,3 @@ 'use strict'; -module.exports = require('./database')(() => require('../prebuilds/darwin-x64.node'), false); +module.exports = require('./with-binding')(() => require('../prebuilds/darwin-x64.node'), false); module.exports.SqliteError = require('./sqlite-error'); diff --git a/lib/database.js b/lib/database.js index af7aff60e..0261ea631 100644 --- a/lib/database.js +++ b/lib/database.js @@ -3,82 +3,119 @@ const fs = require('fs'); const path = require('path'); const util = require('./util'); const SqliteError = require('./sqlite-error'); +const wrappers = require('./methods/wrappers'); -module.exports = function createDatabase(getAddon, allowNativeBinding) { - function Database(filenameGiven, options) { - if (new.target == null) { - return new Database(filenameGiven, options); - } +/** + * A database connection. + * + * Declared at module scope rather than inside a factory so that the type can be + * named. Each package entrypoint exports its own constructor, produced by + * lib/with-binding.js, which supplies the native addon loader for that + * entrypoint. Constructing this base directly is not supported; it has no + * addon loader of its own. + * + * @param {string|Buffer} [filenameGiven] Path to the database file; ':memory:' + * for an in-memory database, an empty string or omitted for a temporary one, + * or a Buffer previously returned by `.serialize()`. + * @param {object} [options] Connection options: `readonly`, `fileMustExist`, + * `timeout` in milliseconds, `verbose`, and `nativeBinding`. + * @returns {Database} The new connection. Returned explicitly so that invoking + * an entrypoint constructor without `new` behaves identically to using `new`. + * + * @throws {TypeError} If an argument or option is invalid, if the containing + * directory does not exist, or if this base is constructed without a bound + * addon loader. + * @throws {RangeError} If `timeout` exceeds 2147483647. + * + * @example + * const Database = require('better-sqlite3'); + * const db = new Database('foobar.db', { verbose: console.log }); + * db.prepare('SELECT 1 AS value').get(); // => { value: 1 } + * + * @see lib/with-binding.js + */ +function Database(filenameGiven, options) { + if (new.target == null) { + return new Database(filenameGiven, options); + } - // Apply defaults - let buffer; - if (Buffer.isBuffer(filenameGiven)) { - buffer = filenameGiven; - filenameGiven = ':memory:'; - } - if (filenameGiven == null) filenameGiven = ''; - if (options == null) options = {}; + // Each entrypoint's constructor carries its own addon loader. Reading it + // from new.target rather than a closure is what allows this function to be + // declared at module scope, and it resolves through the static prototype + // chain so that subclasses of an entrypoint constructor work unchanged. + const bound = new.target[util.binding]; + if (bound == null) { + throw new TypeError('Database cannot be constructed directly; require a better-sqlite3 entrypoint instead'); + } + const { getAddon, allowNativeBinding } = bound; - // Validate arguments - if (typeof filenameGiven !== 'string') throw new TypeError('Expected first argument to be a string'); - if (typeof options !== 'object') throw new TypeError('Expected second argument to be an options object'); - if ('readOnly' in options) throw new TypeError('Misspelled option "readOnly" should be "readonly"'); - if ('memory' in options) throw new TypeError('Option "memory" was removed in v7.0.0 (use ":memory:" filename instead)'); + // Apply defaults + let buffer; + if (Buffer.isBuffer(filenameGiven)) { + buffer = filenameGiven; + filenameGiven = ':memory:'; + } + if (filenameGiven == null) filenameGiven = ''; + if (options == null) options = {}; - // Interpret options - const filename = filenameGiven.trim(); - const anonymous = filename === '' || filename === ':memory:'; - const readonly = util.getBooleanOption(options, 'readonly'); - const fileMustExist = util.getBooleanOption(options, 'fileMustExist'); - const timeout = 'timeout' in options ? options.timeout : 5000; - const verbose = 'verbose' in options ? options.verbose : null; - const nativeBinding = 'nativeBinding' in options ? options.nativeBinding : null; + // Validate arguments + if (typeof filenameGiven !== 'string') throw new TypeError('Expected first argument to be a string'); + if (typeof options !== 'object') throw new TypeError('Expected second argument to be an options object'); + if ('readOnly' in options) throw new TypeError('Misspelled option "readOnly" should be "readonly"'); + if ('memory' in options) throw new TypeError('Option "memory" was removed in v7.0.0 (use ":memory:" filename instead)'); - // Validate interpreted options - if (readonly && anonymous && !buffer) throw new TypeError('In-memory/temporary databases cannot be readonly'); - if (!Number.isInteger(timeout) || timeout < 0) throw new TypeError('Expected the "timeout" option to be a positive integer'); - if (timeout > 0x7fffffff) throw new RangeError('Option "timeout" cannot be greater than 2147483647'); - if (verbose != null && typeof verbose !== 'function') throw new TypeError('Expected the "verbose" option to be a function'); - if (!allowNativeBinding && 'nativeBinding' in options) throw new TypeError('The "nativeBinding" option is only supported by the default better-sqlite3 entrypoint'); - if (allowNativeBinding && nativeBinding != null && typeof nativeBinding !== 'string' && typeof nativeBinding !== 'object') throw new TypeError('Expected the "nativeBinding" option to be a string or addon object'); + // Interpret options + const filename = filenameGiven.trim(); + const anonymous = filename === '' || filename === ':memory:'; + const readonly = util.getBooleanOption(options, 'readonly'); + const fileMustExist = util.getBooleanOption(options, 'fileMustExist'); + const timeout = 'timeout' in options ? options.timeout : 5000; + const verbose = 'verbose' in options ? options.verbose : null; + const nativeBinding = 'nativeBinding' in options ? options.nativeBinding : null; - // Load the native addon - const addon = getAddon(nativeBinding); - if (!addon.isInitialized) { - addon.initialize(SqliteError, arrayFactory, arrayAppender, rowFactory, recordFactory); - addon.isInitialized = true; - } + // Validate interpreted options + if (readonly && anonymous && !buffer) throw new TypeError('In-memory/temporary databases cannot be readonly'); + if (!Number.isInteger(timeout) || timeout < 0) throw new TypeError('Expected the "timeout" option to be a positive integer'); + if (timeout > 0x7fffffff) throw new RangeError('Option "timeout" cannot be greater than 2147483647'); + if (verbose != null && typeof verbose !== 'function') throw new TypeError('Expected the "verbose" option to be a function'); + if (!allowNativeBinding && 'nativeBinding' in options) throw new TypeError('The "nativeBinding" option is only supported by the default better-sqlite3 entrypoint'); + if (allowNativeBinding && nativeBinding != null && typeof nativeBinding !== 'string' && typeof nativeBinding !== 'object') throw new TypeError('Expected the "nativeBinding" option to be a string or addon object'); - // Make sure the specified directory exists - if (!anonymous && !filename.startsWith('file:') && !fs.existsSync(path.dirname(filename))) { - throw new TypeError('Cannot open database because the directory does not exist'); - } + // Load the native addon + const addon = getAddon(nativeBinding); + if (!addon.isInitialized) { + addon.initialize(SqliteError, arrayFactory, arrayAppender, rowFactory, recordFactory); + addon.isInitialized = true; + } - Object.defineProperties(this, { - [util.cppdb]: { value: new addon.Database(filename, filenameGiven, anonymous, readonly, fileMustExist, timeout, verbose || null, buffer || null) }, - ...wrappers.getters, - }); + // Make sure the specified directory exists + if (!anonymous && !filename.startsWith('file:') && !fs.existsSync(path.dirname(filename))) { + throw new TypeError('Cannot open database because the directory does not exist'); } - const wrappers = require('./methods/wrappers'); - Database.prototype.prepare = wrappers.prepare; - Database.prototype.transaction = require('./methods/transaction'); - Database.prototype.pragma = require('./methods/pragma'); - Database.prototype.explain = require('./methods/explain'); - Database.prototype.backup = require('./methods/backup'); - Database.prototype.serialize = require('./methods/serialize'); - Database.prototype.function = require('./methods/function'); - Database.prototype.aggregate = require('./methods/aggregate'); - Database.prototype.table = require('./methods/table'); - Database.prototype.loadExtension = wrappers.loadExtension; - Database.prototype.exec = wrappers.exec; - Database.prototype.close = wrappers.close; - Database.prototype.defaultSafeIntegers = wrappers.defaultSafeIntegers; - Database.prototype.unsafeMode = wrappers.unsafeMode; - Database.prototype[util.inspect] = require('./methods/inspect'); + Object.defineProperties(this, { + [util.cppdb]: { value: new addon.Database(filename, filenameGiven, anonymous, readonly, fileMustExist, timeout, verbose || null, buffer || null) }, + ...wrappers.getters, + }); +} + +Database.prototype.prepare = wrappers.prepare; +Database.prototype.transaction = require('./methods/transaction'); +Database.prototype.pragma = require('./methods/pragma'); +Database.prototype.explain = require('./methods/explain'); +Database.prototype.backup = require('./methods/backup'); +Database.prototype.serialize = require('./methods/serialize'); +Database.prototype.function = require('./methods/function'); +Database.prototype.aggregate = require('./methods/aggregate'); +Database.prototype.table = require('./methods/table'); +Database.prototype.loadExtension = wrappers.loadExtension; +Database.prototype.exec = wrappers.exec; +Database.prototype.close = wrappers.close; +Database.prototype.defaultSafeIntegers = wrappers.defaultSafeIntegers; +Database.prototype.unsafeMode = wrappers.unsafeMode; +Database.prototype[util.inspect] = require('./methods/inspect'); - return Database; -}; +module.exports = Database; function arrayFactory(...values) { return values; diff --git a/lib/index.js b/lib/index.js index d7890fabc..26a1115b3 100644 --- a/lib/index.js +++ b/lib/index.js @@ -1,3 +1,3 @@ 'use strict'; -module.exports = require('./database')(require('./binding').getBinding, true); +module.exports = require('./with-binding')(require('./binding').getBinding, true); module.exports.SqliteError = require('./sqlite-error'); diff --git a/lib/linux-arm64.js b/lib/linux-arm64.js index d6f19f544..3f9879b39 100644 --- a/lib/linux-arm64.js +++ b/lib/linux-arm64.js @@ -1,3 +1,3 @@ 'use strict'; -module.exports = require('./database')(() => require('../prebuilds/linux-arm64.node'), false); +module.exports = require('./with-binding')(() => require('../prebuilds/linux-arm64.node'), false); module.exports.SqliteError = require('./sqlite-error'); diff --git a/lib/linux-x64.js b/lib/linux-x64.js index 7ce601012..693b83a8b 100644 --- a/lib/linux-x64.js +++ b/lib/linux-x64.js @@ -1,3 +1,3 @@ 'use strict'; -module.exports = require('./database')(() => require('../prebuilds/linux-x64.node'), false); +module.exports = require('./with-binding')(() => require('../prebuilds/linux-x64.node'), false); module.exports.SqliteError = require('./sqlite-error'); diff --git a/lib/linuxmusl-arm64.js b/lib/linuxmusl-arm64.js index a24708ece..4f4bae234 100644 --- a/lib/linuxmusl-arm64.js +++ b/lib/linuxmusl-arm64.js @@ -1,4 +1,4 @@ 'use strict'; -const Database = require('./database')(() => require('../prebuilds/linuxmusl-arm64.node'), false); +const Database = require('./with-binding')(() => require('../prebuilds/linuxmusl-arm64.node'), false); Database.SqliteError = require('./sqlite-error'); module.exports = Database; diff --git a/lib/linuxmusl-x64.js b/lib/linuxmusl-x64.js index 84a48be7a..2a9c999e7 100644 --- a/lib/linuxmusl-x64.js +++ b/lib/linuxmusl-x64.js @@ -1,4 +1,4 @@ 'use strict'; -const Database = require('./database')(() => require('../prebuilds/linuxmusl-x64.node'), false); +const Database = require('./with-binding')(() => require('../prebuilds/linuxmusl-x64.node'), false); Database.SqliteError = require('./sqlite-error'); module.exports = Database; diff --git a/lib/util.js b/lib/util.js index 0328c84a5..63bbab001 100644 --- a/lib/util.js +++ b/lib/util.js @@ -9,4 +9,5 @@ exports.getBooleanOption = (options, key) => { }; exports.cppdb = Symbol(); +exports.binding = Symbol(); exports.inspect = Symbol.for('nodejs.util.inspect.custom'); diff --git a/lib/win32-arm64.js b/lib/win32-arm64.js index 0eec9eb79..272459f06 100644 --- a/lib/win32-arm64.js +++ b/lib/win32-arm64.js @@ -1,3 +1,3 @@ 'use strict'; -module.exports = require('./database')(() => require('../prebuilds/win32-arm64.node'), false); +module.exports = require('./with-binding')(() => require('../prebuilds/win32-arm64.node'), false); module.exports.SqliteError = require('./sqlite-error'); diff --git a/lib/win32-x64.js b/lib/win32-x64.js index 34489b05d..09d30f5f4 100644 --- a/lib/win32-x64.js +++ b/lib/win32-x64.js @@ -1,3 +1,3 @@ 'use strict'; -module.exports = require('./database')(() => require('../prebuilds/win32-x64.node'), false); +module.exports = require('./with-binding')(() => require('../prebuilds/win32-x64.node'), false); module.exports.SqliteError = require('./sqlite-error'); diff --git a/lib/with-binding.js b/lib/with-binding.js new file mode 100644 index 000000000..ff64ebbd0 --- /dev/null +++ b/lib/with-binding.js @@ -0,0 +1,63 @@ +'use strict'; +const util = require('./util'); +const Database = require('./database'); + +/** + * Creates a Database constructor bound to a specific native addon loader. + * + * Every package entrypoint calls this once, so each entrypoint exports its own + * constructor with its own prototype. That isolation is deliberate: patching + * one entrypoint's prototype must not affect another's, and an instance from + * one entrypoint must not be `instanceof` another's constructor. + * + * The returned constructor is observationally identical to the one the previous + * factory produced: same name, same arity, same prototype contents, same + * prototype depth, and the same behavior with and without `new`. + * + * @param {Function} getAddon Called with the `nativeBinding` option's value and + * returning the native addon object. + * @param {boolean} allowNativeBinding Whether this entrypoint permits the + * `nativeBinding` option; only the default entrypoint does. + * @returns {Function} A Database constructor bound to `getAddon`. + * + * @example + * // lib/index.js + * module.exports = require('./with-binding')(require('./binding').getBinding, true); + * + * @see lib/database.js + */ +module.exports = function withBinding(getAddon, allowNativeBinding) { + function BoundDatabase(...args) { + // new.target is forwarded so that subclasses construct as themselves. + // Hardcoding BoundDatabase here would silently produce base instances + // from `class Sub extends Database {}`, with no error raised. + return Reflect.construct(Database, args, new.target || BoundDatabase); + } + + // Copy the prototype's descriptors rather than chaining to it, so instances + // sit exactly one link from Object.prototype as they always have. Chaining + // would add a hop to every method lookup and change the observable shape. + // The copy is safe because lib/database.js populates Database.prototype at + // module load, before any entrypoint can call this function. + BoundDatabase.prototype = Object.create( + Object.prototype, + Object.getOwnPropertyDescriptors(Database.prototype), + ); + + // Redefined rather than assigned, to keep `constructor` non-enumerable and + // therefore absent from for..in over an instance. + Object.defineProperty(BoundDatabase.prototype, 'constructor', { + value: BoundDatabase, + writable: true, + enumerable: false, + configurable: true, + }); + + // Preserve the identity callers and stack traces see. + Object.defineProperty(BoundDatabase, 'name', { value: Database.name, configurable: true }); + Object.defineProperty(BoundDatabase, 'length', { value: Database.length, configurable: true }); + + BoundDatabase[util.binding] = { getAddon, allowNativeBinding }; + + return BoundDatabase; +};