diff --git a/doc/api/cli.md b/doc/api/cli.md index adc7e951f61..e9357fb1bee 100644 --- a/doc/api/cli.md +++ b/doc/api/cli.md @@ -1049,6 +1049,14 @@ added: Use this flag to enable [ShadowRealm][] support. +### `--experimental-sqlite` + + + +Enable the experimental [`node:sqlite`][] module. + ### `--experimental-test-coverage` + +An error was returned from [SQLite][]. + ### `ERR_SRI_PARSE` @@ -4023,6 +4033,7 @@ An error occurred trying to allocate memory. This should never happen. [Node.js error codes]: #nodejs-error-codes [Permission Model]: permissions.md#permission-model [RFC 7230 Section 3]: https://tools.ietf.org/html/rfc7230#section-3 +[SQLite]: sqlite.md [Subresource Integrity specification]: https://www.w3.org/TR/SRI/#the-integrity-attribute [V8's stack trace API]: https://v8.dev/docs/stack-trace-api [WHATWG Supported Encodings]: util.md#whatwg-supported-encodings diff --git a/doc/api/index.md b/doc/api/index.md index 81ef77491b1..51915a78d90 100644 --- a/doc/api/index.md +++ b/doc/api/index.md @@ -53,6 +53,7 @@ * [REPL](repl.md) * [Report](report.md) * [Single executable applications](single-executable-applications.md) +* [SQLite](sqlite.md) * [Stream](stream.md) * [String decoder](string_decoder.md) * [Test runner](test.md) diff --git a/doc/api/sqlite.md b/doc/api/sqlite.md new file mode 100644 index 00000000000..313e793a5c5 --- /dev/null +++ b/doc/api/sqlite.md @@ -0,0 +1,328 @@ +# SQLite + + + + + +> Stability: 1.1 - Active development + + + +The `node:sqlite` module facilitates working with SQLite databases. +To access it: + +```mjs +import sqlite from 'node:sqlite'; +``` + +```cjs +const sqlite = require('node:sqlite'); +``` + +This module is only available under the `node:` scheme. The following will not +work: + +```mjs +import sqlite from 'sqlite'; +``` + +```cjs +const sqlite = require('sqlite'); +``` + +The following example shows the basic usage of the `node:sqlite` module to open +an in-memory database, write data to the database, and then read the data back. + +```mjs +import { DatabaseSync } from 'node:sqlite'; +const database = new DatabaseSync(':memory:'); + +// Execute SQL statements from strings. +database.exec(` + CREATE TABLE data( + key INTEGER PRIMARY KEY, + value TEXT + ) STRICT +`); +// Create a prepared statement to insert data into the database. +const insert = database.prepare('INSERT INTO data (key, value) VALUES (?, ?)'); +// Execute the prepared statement with bound values. +insert.run(1, 'hello'); +insert.run(2, 'world'); +// Create a prepared statement to read data from the database. +const query = database.prepare('SELECT * FROM data ORDER BY key'); +// Execute the prepared statement and log the result set. +console.log(query.all()); +// Prints: [ { key: 1, value: 'hello' }, { key: 2, value: 'world' } ] +``` + +```cjs +'use strict'; +const { DatabaseSync } = require('node:sqlite'); +const database = new DatabaseSync(':memory:'); + +// Execute SQL statements from strings. +database.exec(` + CREATE TABLE data( + key INTEGER PRIMARY KEY, + value TEXT + ) STRICT +`); +// Create a prepared statement to insert data into the database. +const insert = database.prepare('INSERT INTO data (key, value) VALUES (?, ?)'); +// Execute the prepared statement with bound values. +insert.run(1, 'hello'); +insert.run(2, 'world'); +// Create a prepared statement to read data from the database. +const query = database.prepare('SELECT * FROM data ORDER BY key'); +// Execute the prepared statement and log the result set. +console.log(query.all()); +// Prints: [ { key: 1, value: 'hello' }, { key: 2, value: 'world' } ] +``` + +## Class: `DatabaseSync` + + + +This class represents a single [connection][] to a SQLite database. All APIs +exposed by this class execute synchronously. + +### `new DatabaseSync(location[, options])` + + + +* `location` {string} The location of the database. A SQLite database can be + stored in a file or completely [in memory][]. To use a file-backed database, + the location should be a file path. To use an in-memory database, the location + should be the special name `':memory:'`. +* `options` {Object} Configuration options for the database connection. The + following options are supported: + * `open` {boolean} If `true`, the database is opened by the constructor. When + this value is `false`, the database must be opened via the `open()` method. + **Default:** `true`. + +Constructs a new `DatabaseSync` instance. + +### `database.close()` + + + +Closes the database connection. An exception is thrown if the database is not +open. This method is a wrapper around [`sqlite3_close_v2()`][]. + +### `database.exec(sql)` + + + +* `sql` {string} A SQL string to execute. + +This method allows one or more SQL statements to be executed without returning +any results. This method is useful when executing SQL statements read from a +file. This method is a wrapper around [`sqlite3_exec()`][]. + +### `database.open()` + + + +Opens the database specified in the `location` argument of the `DatabaseSync` +constructor. This method should only be used when the database is not opened via +the constructor. An exception is thrown if the database is already open. + +### `database.prepare(sql)` + + + +* `sql` {string} A SQL string to compile to a prepared statement. +* Returns: {StatementSync} The prepared statement. + +Compiles a SQL statement into a [prepared statement][]. This method is a wrapper +around [`sqlite3_prepare_v2()`][]. + +## Class: `StatementSync` + + + +This class represents a single [prepared statement][]. This class cannot be +instantiated via its constructor. Instead, instances are created via the +`database.prepare()` method. All APIs exposed by this class execute +synchronously. + +A prepared statement is an efficient binary representation of the SQL used to +create it. Prepared statements are parameterizable, and can be invoked multiple +times with different bound values. Parameters also offer protection against +[SQL injection][] attacks. For these reasons, prepared statements are preferred +over hand-crafted SQL strings when handling user input. + +### `statement.all([namedParameters][, ...anonymousParameters])` + + + +* `namedParameters` {Object} An optional object used to bind named parameters. + The keys of this object are used to configure the mapping. +* `...anonymousParameters` {null|number|bigint|string|Buffer|Uint8Array} Zero or + more values to bind to anonymous parameters. +* Returns: {Array} An array of objects. Each object corresponds to a row + returned by executing the prepared statement. The keys and values of each + object correspond to the column names and values of the row. + +This method executes a prepared statement and returns all results as an array of +objects. If the prepared statement does not return any results, this method +returns an empty array. The prepared statement [parameters are bound][] using +the values in `namedParameters` and `anonymousParameters`. + +### `statement.expandedSQL()` + + + +* Returns: {string} The source SQL expanded to include parameter values. + +This method returns the source SQL of the prepared statement with parameter +placeholders replaced by values. This method is a wrapper around +[`sqlite3_expanded_sql()`][]. + +### `statement.get([namedParameters][, ...anonymousParameters])` + + + +* `namedParameters` {Object} An optional object used to bind named parameters. + The keys of this object are used to configure the mapping. +* `...anonymousParameters` {null|number|bigint|string|Buffer|Uint8Array} Zero or + more values to bind to anonymous parameters. +* Returns: {Object|undefined} An object corresponding to the first row returned + by executing the prepared statement. The keys and values of the object + correspond to the column names and values of the row. If no rows were returned + from the database then this method returns `undefined`. + +This method executes a prepared statement and returns the first result as an +object. If the prepared statement does not return any results, this method +returns `undefined`. The prepared statement [parameters are bound][] using the +values in `namedParameters` and `anonymousParameters`. + +### `statement.run([namedParameters][, ...anonymousParameters])` + + + +* `namedParameters` {Object} An optional object used to bind named parameters. + The keys of this object are used to configure the mapping. +* `...anonymousParameters` {null|number|bigint|string|Buffer|Uint8Array} Zero or + more values to bind to anonymous parameters. +* Returns: {Object} + * `changes`: {number|bigint} The number of rows modified, inserted, or deleted + by the most recently completed `INSERT`, `UPDATE`, or `DELETE` statement. + This field is either a number or a `BigInt` depending on the prepared + statement's configuration. This property is the result of + [`sqlite3_changes64()`][]. + * `lastInsertRowid`: {number|bigint} The most recently inserted rowid. This + field is either a number or a `BigInt` depending on the prepared statement's + configuration. This property is the result of + [`sqlite3_last_insert_rowid()`][]. + +This method executes a prepared statement and returns an object summarizing the +resulting changes. The prepared statement [parameters are bound][] using the +values in `namedParameters` and `anonymousParameters`. + +### `statement.setAllowBareNamedParameters(enabled)` + + + +* `enabled` {boolean} Enables or disables support for binding named parameters + without the prefix character. + +The names of SQLite parameters begin with a prefix character. By default, +`node:sqlite` requires that this prefix character is present when binding +parameters. However, with the exception of dollar sign character, these +prefix characters also require extra quoting when used in object keys. + +To improve ergonomics, this method can be used to also allow bare named +parameters, which do not require the prefix character in JavaScript code. There +are several caveats to be aware of when enabling bare named parameters: + +* The prefix character is still required in SQL. +* The prefix character is still allowed in JavaScript. In fact, prefixed names + will have slightly better binding performance. +* Using ambiguous named parameters, such as `$k` and `@k`, in the same prepared + statement will result in an exception as it cannot be determined how to bind + a bare name. + +### `statement.setReadBigInts(enabled)` + + + +* `enabled` {boolean} Enables or disables the use of `BigInt`s when reading + `INTEGER` fields from the database. + +When reading from the database, SQLite `INTEGER`s are mapped to JavaScript +numbers by default. However, SQLite `INTEGER`s can store values larger than +JavaScript numbers are capable of representing. In such cases, this method can +be used to read `INTEGER` data using JavaScript `BigInt`s. This method has no +impact on database write operations where numbers and `BigInt`s are both +supported at all times. + +### `statement.sourceSQL()` + + + +* Returns: {string} The source SQL used to create this prepared statement. + +This method returns the source SQL of the prepared statement. This method is a +wrapper around [`sqlite3_sql()`][]. + +### Type conversion between JavaScript and SQLite + +When Node.js writes to or reads from SQLite it is necessary to convert between +JavaScript data types and SQLite's [data types][]. Because JavaScript supports +more data types than SQLite, only a subset of JavaScript types are supported. +Attempting to write an unsupported data type to SQLite will result in an +exception. + +| SQLite | JavaScript | +| --------- | -------------------- | +| `NULL` | `null` | +| `INTEGER` | `number` or `BigInt` | +| `REAL` | `number` | +| `TEXT` | `string` | +| `BLOB` | `Uint8Array` | + +[SQL injection]: https://en.wikipedia.org/wiki/SQL_injection +[`sqlite3_changes64()`]: https://www.sqlite.org/c3ref/changes.html +[`sqlite3_close_v2()`]: https://www.sqlite.org/c3ref/close.html +[`sqlite3_exec()`]: https://www.sqlite.org/c3ref/exec.html +[`sqlite3_expanded_sql()`]: https://www.sqlite.org/c3ref/expanded_sql.html +[`sqlite3_last_insert_rowid()`]: https://www.sqlite.org/c3ref/last_insert_rowid.html +[`sqlite3_prepare_v2()`]: https://www.sqlite.org/c3ref/prepare.html +[`sqlite3_sql()`]: https://www.sqlite.org/c3ref/expanded_sql.html +[connection]: https://www.sqlite.org/c3ref/sqlite3.html +[data types]: https://www.sqlite.org/datatype3.html +[in memory]: https://www.sqlite.org/inmemorydb.html +[parameters are bound]: https://www.sqlite.org/c3ref/bind_blob.html +[prepared statement]: https://www.sqlite.org/c3ref/stmt.html diff --git a/doc/node.1 b/doc/node.1 index 01436ff9b71..dabf354689a 100644 --- a/doc/node.1 +++ b/doc/node.1 @@ -182,6 +182,9 @@ Enable the experimental permission model. .It Fl -experimental-shadow-realm Use this flag to enable ShadowRealm support. . +.It Fl -experimental-sqlite +Enable the experimental node:sqlite module. +. .It Fl -experimental-test-coverage Enable code coverage in the test runner. . diff --git a/lib/internal/bootstrap/realm.js b/lib/internal/bootstrap/realm.js index 667f1e0909d..c11f70dd6bf 100644 --- a/lib/internal/bootstrap/realm.js +++ b/lib/internal/bootstrap/realm.js @@ -129,11 +129,12 @@ const legacyWrapperList = new SafeSet([ // Modules that can only be imported via the node: scheme. const schemelessBlockList = new SafeSet([ 'sea', + 'sqlite', 'test', 'test/reporters', ]); // Modules that will only be enabled at run time. -const experimentalModuleList = new SafeSet(); +const experimentalModuleList = new SafeSet(['sqlite']); // Set up process.binding() and process._linkedBinding(). { diff --git a/lib/internal/process/pre_execution.js b/lib/internal/process/pre_execution.js index 27df0a9440a..2c8d77f39e1 100644 --- a/lib/internal/process/pre_execution.js +++ b/lib/internal/process/pre_execution.js @@ -100,6 +100,7 @@ function prepareExecution(options) { setupInspectorHooks(); setupNavigator(); setupWarningHandler(); + setupSQLite(); setupWebStorage(); setupWebsocket(); setupEventsource(); @@ -329,6 +330,15 @@ function setupNavigator() { defineReplaceableLazyAttribute(globalThis, 'internal/navigator', ['navigator'], false); } +function setupSQLite() { + if (!getOptionValue('--experimental-sqlite')) { + return; + } + + const { BuiltinModule } = require('internal/bootstrap/realm'); + BuiltinModule.allowRequireByUsers('sqlite'); +} + function setupWebStorage() { if (getEmbedderOptions().noBrowserGlobals || !getOptionValue('--experimental-webstorage')) { diff --git a/lib/sqlite.js b/lib/sqlite.js new file mode 100644 index 00000000000..ca3d9544f15 --- /dev/null +++ b/lib/sqlite.js @@ -0,0 +1,5 @@ +'use strict'; +const { emitExperimentalWarning } = require('internal/util'); + +emitExperimentalWarning('SQLite'); +module.exports = internalBinding('sqlite'); diff --git a/node.gyp b/node.gyp index bb3de4497f0..9617596760a 100644 --- a/node.gyp +++ b/node.gyp @@ -135,6 +135,7 @@ 'src/node_shadow_realm.cc', 'src/node_snapshotable.cc', 'src/node_sockaddr.cc', + 'src/node_sqlite.cc', 'src/node_stat_watcher.cc', 'src/node_symbols.cc', 'src/node_task_queue.cc', @@ -264,6 +265,7 @@ 'src/node_snapshot_builder.h', 'src/node_sockaddr.h', 'src/node_sockaddr-inl.h', + 'src/node_sqlite.h', 'src/node_stat_watcher.h', 'src/node_union_bytes.h', 'src/node_url.h', diff --git a/src/env_properties.h b/src/env_properties.h index eb5c51b6e2b..5b322503259 100644 --- a/src/env_properties.h +++ b/src/env_properties.h @@ -396,6 +396,7 @@ V(secure_context_constructor_template, v8::FunctionTemplate) \ V(shutdown_wrap_template, v8::ObjectTemplate) \ V(socketaddress_constructor_template, v8::FunctionTemplate) \ + V(sqlite_statement_sync_constructor_template, v8::FunctionTemplate) \ V(streambaseentry_ctor_template, v8::FunctionTemplate) \ V(streambaseoutputstream_constructor_template, v8::ObjectTemplate) \ V(streamentry_ctor_template, v8::FunctionTemplate) \ diff --git a/src/node_binding.cc b/src/node_binding.cc index 79c54fcbc7b..9b75a9eeda9 100644 --- a/src/node_binding.cc +++ b/src/node_binding.cc @@ -67,6 +67,7 @@ V(serdes) \ V(signal_wrap) \ V(spawn_sync) \ + V(sqlite) \ V(stream_pipe) \ V(stream_wrap) \ V(string_decoder) \ diff --git a/src/node_builtins.cc b/src/node_builtins.cc index 1ffe2e13aae..1702ac0ac53 100644 --- a/src/node_builtins.cc +++ b/src/node_builtins.cc @@ -125,8 +125,9 @@ BuiltinLoader::BuiltinCategories BuiltinLoader::GetBuiltinCategories() const { "internal/http2/core", "internal/http2/compat", "internal/streams/lazy_transform", #endif // !HAVE_OPENSSL - "sys", // Deprecated. - "wasi", // Experimental. + "sqlite", // Experimental. + "sys", // Deprecated. + "wasi", // Experimental. "internal/test/binding", "internal/v8_prof_polyfill", "internal/v8_prof_processor", }; diff --git a/src/node_options.cc b/src/node_options.cc index 2e43bf8242c..1f4e402964c 100644 --- a/src/node_options.cc +++ b/src/node_options.cc @@ -410,6 +410,10 @@ EnvironmentOptionsParser::EnvironmentOptionsParser() { kAllowedInEnvvar, true); AddOption("--experimental-global-customevent", "", NoOp{}, kAllowedInEnvvar); + AddOption("--experimental-sqlite", + "experimental node:sqlite module", + &EnvironmentOptions::experimental_sqlite, + kAllowedInEnvvar); AddOption("--experimental-webstorage", "experimental Web Storage API", &EnvironmentOptions::experimental_webstorage, diff --git a/src/node_options.h b/src/node_options.h index 489638739c3..7ffc7448cab 100644 --- a/src/node_options.h +++ b/src/node_options.h @@ -118,6 +118,7 @@ class EnvironmentOptions : public Options { bool experimental_eventsource = false; bool experimental_fetch = true; bool experimental_websocket = true; + bool experimental_sqlite = false; bool experimental_webstorage = false; std::string localstorage_file; bool experimental_global_navigator = true; diff --git a/src/node_sqlite.cc b/src/node_sqlite.cc new file mode 100644 index 00000000000..cb7855a2ad1 --- /dev/null +++ b/src/node_sqlite.cc @@ -0,0 +1,667 @@ +#include "node_sqlite.h" +#include "base_object-inl.h" +#include "debug_utils-inl.h" +#include "env-inl.h" +#include "memory_tracker-inl.h" +#include "node.h" +#include "node_errors.h" +#include "node_mem-inl.h" +#include "sqlite3.h" +#include "util-inl.h" + +#include + +namespace node { +namespace sqlite { + +using v8::Array; +using v8::ArrayBuffer; +using v8::BigInt; +using v8::Boolean; +using v8::Context; +using v8::Exception; +using v8::FunctionCallbackInfo; +using v8::FunctionTemplate; +using v8::Integer; +using v8::Isolate; +using v8::Local; +using v8::Number; +using v8::Object; +using v8::String; +using v8::Uint8Array; +using v8::Value; + +#define CHECK_ERROR_OR_THROW(isolate, db, expr, expected, ret) \ + do { \ + int r_ = (expr); \ + if (r_ != (expected)) { \ + THROW_ERR_SQLITE_ERROR((isolate), (db)); \ + return (ret); \ + } \ + } while (0) + +#define THROW_AND_RETURN_ON_BAD_STATE(env, condition, msg) \ + do { \ + if ((condition)) { \ + node::THROW_ERR_INVALID_STATE((env), (msg)); \ + return; \ + } \ + } while (0) + +inline Local CreateSQLiteError(Isolate* isolate, sqlite3* db) { + int errcode = sqlite3_extended_errcode(db); + const char* errstr = sqlite3_errstr(errcode); + const char* errmsg = sqlite3_errmsg(db); + Local js_msg = String::NewFromUtf8(isolate, errmsg).ToLocalChecked(); + Local e = Exception::Error(js_msg) + ->ToObject(isolate->GetCurrentContext()) + .ToLocalChecked(); + e->Set(isolate->GetCurrentContext(), + OneByteString(isolate, "code"), + OneByteString(isolate, "ERR_SQLITE_ERROR")) + .Check(); + e->Set(isolate->GetCurrentContext(), + OneByteString(isolate, "errcode"), + Integer::New(isolate, errcode)) + .Check(); + e->Set(isolate->GetCurrentContext(), + OneByteString(isolate, "errstr"), + String::NewFromUtf8(isolate, errstr).ToLocalChecked()) + .Check(); + return e; +} + +inline void THROW_ERR_SQLITE_ERROR(Isolate* isolate, sqlite3* db) { + isolate->ThrowException(CreateSQLiteError(isolate, db)); +} + +DatabaseSync::DatabaseSync(Environment* env, + Local object, + Local location, + bool open) + : BaseObject(env, object) { + MakeWeak(); + node::Utf8Value utf8_location(env->isolate(), location); + location_ = utf8_location.ToString(); + connection_ = nullptr; + + if (open) { + Open(); + } +} + +DatabaseSync::~DatabaseSync() { + sqlite3_close_v2(connection_); + connection_ = nullptr; +} + +void DatabaseSync::MemoryInfo(MemoryTracker* tracker) const { + tracker->TrackField("location", location_); +} + +bool DatabaseSync::Open() { + if (connection_ != nullptr) { + node::THROW_ERR_INVALID_STATE(env(), "database is already open"); + return false; + } + + // TODO(cjihrig): Support additional flags. + int flags = SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE; + int r = sqlite3_open_v2(location_.c_str(), &connection_, flags, nullptr); + CHECK_ERROR_OR_THROW(env()->isolate(), connection_, r, SQLITE_OK, false); + return true; +} + +void DatabaseSync::New(const FunctionCallbackInfo& args) { + Environment* env = Environment::GetCurrent(args); + + if (!args.IsConstructCall()) { + THROW_ERR_CONSTRUCT_CALL_REQUIRED(env); + return; + } + + if (!args[0]->IsString()) { + node::THROW_ERR_INVALID_ARG_TYPE(env->isolate(), + "The \"path\" argument must be a string."); + return; + } + + bool open = true; + + if (args.Length() > 1) { + if (!args[1]->IsObject()) { + node::THROW_ERR_INVALID_ARG_TYPE( + env->isolate(), "The \"options\" argument must be an object."); + return; + } + + Local options = args[1].As(); + Local open_string = FIXED_ONE_BYTE_STRING(env->isolate(), "open"); + Local open_v; + if (!options->Get(env->context(), open_string).ToLocal(&open_v)) { + return; + } + if (!open_v->IsUndefined()) { + if (!open_v->IsBoolean()) { + node::THROW_ERR_INVALID_ARG_TYPE( + env->isolate(), "The \"options.open\" argument must be a boolean."); + return; + } + open = open_v.As()->Value(); + } + } + + new DatabaseSync(env, args.This(), args[0].As(), open); +} + +void DatabaseSync::Open(const FunctionCallbackInfo& args) { + DatabaseSync* db; + ASSIGN_OR_RETURN_UNWRAP(&db, args.This()); + db->Open(); +} + +void DatabaseSync::Close(const FunctionCallbackInfo& args) { + DatabaseSync* db; + ASSIGN_OR_RETURN_UNWRAP(&db, args.This()); + Environment* env = Environment::GetCurrent(args); + THROW_AND_RETURN_ON_BAD_STATE( + env, db->connection_ == nullptr, "database is not open"); + int r = sqlite3_close_v2(db->connection_); + CHECK_ERROR_OR_THROW(env->isolate(), db->connection_, r, SQLITE_OK, void()); + db->connection_ = nullptr; +} + +void DatabaseSync::Prepare(const FunctionCallbackInfo& args) { + DatabaseSync* db; + ASSIGN_OR_RETURN_UNWRAP(&db, args.This()); + Environment* env = Environment::GetCurrent(args); + THROW_AND_RETURN_ON_BAD_STATE( + env, db->connection_ == nullptr, "database is not open"); + + if (!args[0]->IsString()) { + node::THROW_ERR_INVALID_ARG_TYPE(env->isolate(), + "The \"sql\" argument must be a string."); + return; + } + + auto sql = node::Utf8Value(env->isolate(), args[0].As()); + sqlite3_stmt* s = nullptr; + int r = sqlite3_prepare_v2(db->connection_, *sql, -1, &s, 0); + CHECK_ERROR_OR_THROW(env->isolate(), db->connection_, r, SQLITE_OK, void()); + BaseObjectPtr stmt = + StatementSync::Create(env, db->connection_, s); + args.GetReturnValue().Set(stmt->object()); +} + +void DatabaseSync::Exec(const FunctionCallbackInfo& args) { + DatabaseSync* db; + ASSIGN_OR_RETURN_UNWRAP(&db, args.This()); + Environment* env = Environment::GetCurrent(args); + THROW_AND_RETURN_ON_BAD_STATE( + env, db->connection_ == nullptr, "database is not open"); + + if (!args[0]->IsString()) { + node::THROW_ERR_INVALID_ARG_TYPE(env->isolate(), + "The \"sql\" argument must be a string."); + return; + } + + auto sql = node::Utf8Value(env->isolate(), args[0].As()); + int r = sqlite3_exec(db->connection_, *sql, nullptr, nullptr, nullptr); + CHECK_ERROR_OR_THROW(env->isolate(), db->connection_, r, SQLITE_OK, void()); +} + +StatementSync::StatementSync(Environment* env, + Local object, + sqlite3* db, + sqlite3_stmt* stmt) + : BaseObject(env, object) { + MakeWeak(); + db_ = db; + statement_ = stmt; + // In the future, some of these options could be set at the database + // connection level and inherited by statements to reduce boilerplate. + use_big_ints_ = false; + allow_bare_named_params_ = true; + bare_named_params_ = std::nullopt; +} + +StatementSync::~StatementSync() { + sqlite3_finalize(statement_); + statement_ = nullptr; +} + +bool StatementSync::BindParams(const FunctionCallbackInfo& args) { + int r = sqlite3_clear_bindings(statement_); + CHECK_ERROR_OR_THROW(env()->isolate(), db_, r, SQLITE_OK, false); + + int anon_idx = 1; + int anon_start = 0; + + if (args[0]->IsObject() && !args[0]->IsUint8Array()) { + Local obj = args[0].As(); + Local context = obj->GetIsolate()->GetCurrentContext(); + Local keys; + if (!obj->GetOwnPropertyNames(context).ToLocal(&keys)) { + return false; + } + + if (allow_bare_named_params_ && !bare_named_params_.has_value()) { + bare_named_params_.emplace(); + int param_count = sqlite3_bind_parameter_count(statement_); + // Parameter indexing starts at one. + for (int i = 1; i <= param_count; ++i) { + const char* name = sqlite3_bind_parameter_name(statement_, i); + if (name == nullptr) { + continue; + } + + auto bare_name = std::string(name + 1); + auto full_name = std::string(name); + auto insertion = bare_named_params_->insert({bare_name, full_name}); + if (insertion.second == false) { + auto existing_full_name = (*insertion.first).second; + if (full_name != existing_full_name) { + node::THROW_ERR_INVALID_STATE( + env(), + "Cannot create bare named parameter '%s' because of " + "conflicting names '%s' and '%s'.", + bare_name, + existing_full_name, + full_name); + return false; + } + } + } + } + + uint32_t len = keys->Length(); + for (uint32_t j = 0; j < len; j++) { + Local key; + if (!keys->Get(context, j).ToLocal(&key)) { + return false; + } + + auto utf8_key = node::Utf8Value(env()->isolate(), key); + int r = sqlite3_bind_parameter_index(statement_, *utf8_key); + if (r == 0) { + if (allow_bare_named_params_) { + auto lookup = bare_named_params_->find(std::string(*utf8_key)); + if (lookup != bare_named_params_->end()) { + r = sqlite3_bind_parameter_index(statement_, + lookup->second.c_str()); + } + } + + if (r == 0) { + node::THROW_ERR_INVALID_STATE( + env(), "Unknown named parameter '%s'", *utf8_key); + return false; + } + } + + Local value; + if (!obj->Get(context, key).ToLocal(&value)) { + return false; + } + + if (!BindValue(value, r)) { + return false; + } + } + anon_start++; + } + + for (int i = anon_start; i < args.Length(); ++i) { + while (sqlite3_bind_parameter_name(statement_, anon_idx) != nullptr) { + anon_idx++; + } + + if (!BindValue(args[i], anon_idx)) { + return false; + } + + anon_idx++; + } + + return true; +} + +bool StatementSync::BindValue(const Local& value, const int index) { + // SQLite only supports a subset of JavaScript types. Some JS types such as + // functions don't make sense to support. Other JS types such as booleans and + // Dates could be supported by converting them to numbers. However, there + // would not be a good way to read the values back from SQLite with the + // original type. + int r; + if (value->IsNumber()) { + double val = value.As()->Value(); + r = sqlite3_bind_double(statement_, index, val); + } else if (value->IsString()) { + auto val = node::Utf8Value(env()->isolate(), value.As()); + r = sqlite3_bind_text( + statement_, index, *val, val.length(), SQLITE_TRANSIENT); + } else if (value->IsNull()) { + r = sqlite3_bind_null(statement_, index); + } else if (value->IsUint8Array()) { + ArrayBufferViewContents buf(value); + r = sqlite3_bind_blob( + statement_, index, buf.data(), buf.length(), SQLITE_TRANSIENT); + } else if (value->IsBigInt()) { + bool lossless; + int64_t as_int = value.As()->Int64Value(&lossless); + if (!lossless) { + node::THROW_ERR_INVALID_ARG_VALUE(env(), + "BigInt value is too large to bind."); + return false; + } + r = sqlite3_bind_int64(statement_, index, as_int); + } else { + node::THROW_ERR_INVALID_ARG_TYPE( + env()->isolate(), + "Provided value cannot be bound to SQLite parameter %d.", + index); + return false; + } + + CHECK_ERROR_OR_THROW(env()->isolate(), db_, r, SQLITE_OK, false); + return true; +} + +Local StatementSync::ColumnToValue(const int column) { + switch (sqlite3_column_type(statement_, column)) { + case SQLITE_INTEGER: + if (use_big_ints_) { + return BigInt::New(env()->isolate(), + sqlite3_column_int64(statement_, column)); + } + // Fall through. + case SQLITE_FLOAT: + return Number::New(env()->isolate(), + sqlite3_column_double(statement_, column)); + case SQLITE_TEXT: { + const char* value = reinterpret_cast( + sqlite3_column_text(statement_, column)); + Local val; + if (!String::NewFromUtf8(env()->isolate(), value).ToLocal(&val)) { + return Local(); + } + return val; + } + case SQLITE_NULL: + return v8::Null(env()->isolate()); + case SQLITE_BLOB: { + size_t size = + static_cast(sqlite3_column_bytes(statement_, column)); + auto data = reinterpret_cast( + sqlite3_column_blob(statement_, column)); + auto store = ArrayBuffer::NewBackingStore(env()->isolate(), size); + memcpy(store->Data(), data, size); + auto ab = ArrayBuffer::New(env()->isolate(), std::move(store)); + return Uint8Array::New(ab, 0, size); + } + default: + UNREACHABLE("Bad SQLite column type"); + } +} + +Local StatementSync::ColumnNameToValue(const int column) { + const char* col_name = sqlite3_column_name(statement_, column); + if (col_name == nullptr) { + node::THROW_ERR_INVALID_STATE( + env(), "Cannot get name of column %d", column); + return Local(); + } + + Local key; + if (!String::NewFromUtf8(env()->isolate(), col_name).ToLocal(&key)) { + return Local(); + } + return key; +} + +void StatementSync::MemoryInfo(MemoryTracker* tracker) const {} + +void StatementSync::All(const FunctionCallbackInfo& args) { + StatementSync* stmt; + ASSIGN_OR_RETURN_UNWRAP(&stmt, args.This()); + Environment* env = Environment::GetCurrent(args); + int r = sqlite3_reset(stmt->statement_); + CHECK_ERROR_OR_THROW(env->isolate(), stmt->db_, r, SQLITE_OK, void()); + + if (!stmt->BindParams(args)) { + return; + } + + auto reset = OnScopeLeave([&]() { sqlite3_reset(stmt->statement_); }); + int num_cols = sqlite3_column_count(stmt->statement_); + std::vector> rows; + while ((r = sqlite3_step(stmt->statement_)) == SQLITE_ROW) { + Local row = Object::New(env->isolate()); + + for (int i = 0; i < num_cols; ++i) { + Local key = stmt->ColumnNameToValue(i); + Local val = stmt->ColumnToValue(i); + + if (row->Set(env->context(), key, val).IsNothing()) { + return; + } + } + + rows.emplace_back(row); + } + + CHECK_ERROR_OR_THROW(env->isolate(), stmt->db_, r, SQLITE_DONE, void()); + args.GetReturnValue().Set( + Array::New(env->isolate(), rows.data(), rows.size())); +} + +void StatementSync::Get(const FunctionCallbackInfo& args) { + StatementSync* stmt; + ASSIGN_OR_RETURN_UNWRAP(&stmt, args.This()); + Environment* env = Environment::GetCurrent(args); + int r = sqlite3_reset(stmt->statement_); + CHECK_ERROR_OR_THROW(env->isolate(), stmt->db_, r, SQLITE_OK, void()); + + if (!stmt->BindParams(args)) { + return; + } + + auto reset = OnScopeLeave([&]() { sqlite3_reset(stmt->statement_); }); + r = sqlite3_step(stmt->statement_); + if (r != SQLITE_ROW && r != SQLITE_DONE) { + THROW_ERR_SQLITE_ERROR(env->isolate(), stmt->db_); + return; + } + + int num_cols = sqlite3_column_count(stmt->statement_); + if (num_cols == 0) { + return; + } + + Local result = Object::New(env->isolate()); + + for (int i = 0; i < num_cols; ++i) { + Local key = stmt->ColumnNameToValue(i); + Local val = stmt->ColumnToValue(i); + + if (result->Set(env->context(), key, val).IsNothing()) { + return; + } + } + + args.GetReturnValue().Set(result); +} + +void StatementSync::Run(const FunctionCallbackInfo& args) { + StatementSync* stmt; + ASSIGN_OR_RETURN_UNWRAP(&stmt, args.This()); + Environment* env = Environment::GetCurrent(args); + int r = sqlite3_reset(stmt->statement_); + CHECK_ERROR_OR_THROW(env->isolate(), stmt->db_, r, SQLITE_OK, void()); + + if (!stmt->BindParams(args)) { + return; + } + + auto reset = OnScopeLeave([&]() { sqlite3_reset(stmt->statement_); }); + r = sqlite3_step(stmt->statement_); + if (r != SQLITE_ROW && r != SQLITE_DONE) { + THROW_ERR_SQLITE_ERROR(env->isolate(), stmt->db_); + return; + } + + Local result = Object::New(env->isolate()); + Local last_insert_rowid_string = + FIXED_ONE_BYTE_STRING(env->isolate(), "lastInsertRowid"); + Local changes_string = + FIXED_ONE_BYTE_STRING(env->isolate(), "changes"); + sqlite3_int64 last_insert_rowid = sqlite3_last_insert_rowid(stmt->db_); + sqlite3_int64 changes = sqlite3_changes64(stmt->db_); + Local last_insert_rowid_val; + Local changes_val; + + if (stmt->use_big_ints_) { + last_insert_rowid_val = BigInt::New(env->isolate(), last_insert_rowid); + changes_val = BigInt::New(env->isolate(), changes); + } else { + last_insert_rowid_val = Number::New(env->isolate(), last_insert_rowid); + changes_val = Number::New(env->isolate(), changes); + } + + if (result + ->Set(env->context(), last_insert_rowid_string, last_insert_rowid_val) + .IsNothing() || + result->Set(env->context(), changes_string, changes_val).IsNothing()) { + return; + } + + args.GetReturnValue().Set(result); +} + +void StatementSync::SourceSQL(const FunctionCallbackInfo& args) { + StatementSync* stmt; + ASSIGN_OR_RETURN_UNWRAP(&stmt, args.This()); + Environment* env = Environment::GetCurrent(args); + Local sql; + if (!String::NewFromUtf8(env->isolate(), sqlite3_sql(stmt->statement_)) + .ToLocal(&sql)) { + return; + } + args.GetReturnValue().Set(sql); +} + +void StatementSync::ExpandedSQL(const FunctionCallbackInfo& args) { + StatementSync* stmt; + ASSIGN_OR_RETURN_UNWRAP(&stmt, args.This()); + Environment* env = Environment::GetCurrent(args); + char* expanded = sqlite3_expanded_sql(stmt->statement_); + auto maybe_expanded = String::NewFromUtf8(env->isolate(), expanded); + sqlite3_free(expanded); + Local result; + if (!maybe_expanded.ToLocal(&result)) { + return; + } + args.GetReturnValue().Set(result); +} + +void StatementSync::SetAllowBareNamedParameters( + const FunctionCallbackInfo& args) { + StatementSync* stmt; + ASSIGN_OR_RETURN_UNWRAP(&stmt, args.This()); + Environment* env = Environment::GetCurrent(args); + + if (!args[0]->IsBoolean()) { + node::THROW_ERR_INVALID_ARG_TYPE( + env->isolate(), + "The \"allowBareNamedParameters\" argument must be a boolean."); + return; + } + + stmt->allow_bare_named_params_ = args[0]->IsTrue(); +} + +void StatementSync::SetReadBigInts(const FunctionCallbackInfo& args) { + StatementSync* stmt; + ASSIGN_OR_RETURN_UNWRAP(&stmt, args.This()); + Environment* env = Environment::GetCurrent(args); + + if (!args[0]->IsBoolean()) { + node::THROW_ERR_INVALID_ARG_TYPE( + env->isolate(), "The \"readBigInts\" argument must be a boolean."); + return; + } + + stmt->use_big_ints_ = args[0]->IsTrue(); +} + +void IllegalConstructor(const FunctionCallbackInfo& args) { + node::THROW_ERR_ILLEGAL_CONSTRUCTOR(Environment::GetCurrent(args)); +} + +Local StatementSync::GetConstructorTemplate( + Environment* env) { + Local tmpl = + env->sqlite_statement_sync_constructor_template(); + if (tmpl.IsEmpty()) { + Isolate* isolate = env->isolate(); + tmpl = NewFunctionTemplate(isolate, IllegalConstructor); + tmpl->SetClassName(FIXED_ONE_BYTE_STRING(env->isolate(), "StatementSync")); + tmpl->InstanceTemplate()->SetInternalFieldCount( + StatementSync::kInternalFieldCount); + SetProtoMethod(isolate, tmpl, "all", StatementSync::All); + SetProtoMethod(isolate, tmpl, "get", StatementSync::Get); + SetProtoMethod(isolate, tmpl, "run", StatementSync::Run); + SetProtoMethod(isolate, tmpl, "sourceSQL", StatementSync::SourceSQL); + SetProtoMethod(isolate, tmpl, "expandedSQL", StatementSync::ExpandedSQL); + SetProtoMethod(isolate, + tmpl, + "setAllowBareNamedParameters", + StatementSync::SetAllowBareNamedParameters); + SetProtoMethod( + isolate, tmpl, "setReadBigInts", StatementSync::SetReadBigInts); + env->set_sqlite_statement_sync_constructor_template(tmpl); + } + return tmpl; +} + +BaseObjectPtr StatementSync::Create(Environment* env, + sqlite3* db, + sqlite3_stmt* stmt) { + Local obj; + if (!GetConstructorTemplate(env) + ->InstanceTemplate() + ->NewInstance(env->context()) + .ToLocal(&obj)) { + return BaseObjectPtr(); + } + + return MakeBaseObject(env, obj, db, stmt); +} + +static void Initialize(Local target, + Local unused, + Local context, + void* priv) { + Environment* env = Environment::GetCurrent(context); + Isolate* isolate = env->isolate(); + Local db_tmpl = + NewFunctionTemplate(isolate, DatabaseSync::New); + db_tmpl->InstanceTemplate()->SetInternalFieldCount( + DatabaseSync::kInternalFieldCount); + + SetProtoMethod(isolate, db_tmpl, "open", DatabaseSync::Open); + SetProtoMethod(isolate, db_tmpl, "close", DatabaseSync::Close); + SetProtoMethod(isolate, db_tmpl, "prepare", DatabaseSync::Prepare); + SetProtoMethod(isolate, db_tmpl, "exec", DatabaseSync::Exec); + SetConstructorFunction(context, target, "DatabaseSync", db_tmpl); + SetConstructorFunction(context, + target, + "StatementSync", + StatementSync::GetConstructorTemplate(env)); +} + +} // namespace sqlite +} // namespace node + +NODE_BINDING_CONTEXT_AWARE_INTERNAL(sqlite, node::sqlite::Initialize) diff --git a/src/node_sqlite.h b/src/node_sqlite.h new file mode 100644 index 00000000000..56b937a7196 --- /dev/null +++ b/src/node_sqlite.h @@ -0,0 +1,81 @@ +#ifndef SRC_NODE_SQLITE_H_ +#define SRC_NODE_SQLITE_H_ + +#if defined(NODE_WANT_INTERNALS) && NODE_WANT_INTERNALS + +#include "base_object.h" +#include "node_mem.h" +#include "sqlite3.h" +#include "util.h" + +#include + +namespace node { +namespace sqlite { + +class DatabaseSync : public BaseObject { + public: + DatabaseSync(Environment* env, + v8::Local object, + v8::Local location, + bool open); + void MemoryInfo(MemoryTracker* tracker) const override; + static void New(const v8::FunctionCallbackInfo& args); + static void Open(const v8::FunctionCallbackInfo& args); + static void Close(const v8::FunctionCallbackInfo& args); + static void Prepare(const v8::FunctionCallbackInfo& args); + static void Exec(const v8::FunctionCallbackInfo& args); + + SET_MEMORY_INFO_NAME(DatabaseSync) + SET_SELF_SIZE(DatabaseSync) + + private: + bool Open(); + + ~DatabaseSync() override; + std::string location_; + sqlite3* connection_; +}; + +class StatementSync : public BaseObject { + public: + StatementSync(Environment* env, + v8::Local object, + sqlite3* db, + sqlite3_stmt* stmt); + void MemoryInfo(MemoryTracker* tracker) const override; + static v8::Local GetConstructorTemplate( + Environment* env); + static BaseObjectPtr Create(Environment* env, + sqlite3* db, + sqlite3_stmt* stmt); + static void All(const v8::FunctionCallbackInfo& args); + static void Get(const v8::FunctionCallbackInfo& args); + static void Run(const v8::FunctionCallbackInfo& args); + static void SourceSQL(const v8::FunctionCallbackInfo& args); + static void ExpandedSQL(const v8::FunctionCallbackInfo& args); + static void SetAllowBareNamedParameters( + const v8::FunctionCallbackInfo& args); + static void SetReadBigInts(const v8::FunctionCallbackInfo& args); + + SET_MEMORY_INFO_NAME(StatementSync) + SET_SELF_SIZE(StatementSync) + + private: + ~StatementSync() override; + sqlite3* db_; + sqlite3_stmt* statement_; + bool use_big_ints_; + bool allow_bare_named_params_; + std::optional> bare_named_params_; + bool BindParams(const v8::FunctionCallbackInfo& args); + bool BindValue(const v8::Local& value, const int index); + v8::Local ColumnToValue(const int column); + v8::Local ColumnNameToValue(const int column); +}; + +} // namespace sqlite +} // namespace node + +#endif // defined(NODE_WANT_INTERNALS) && NODE_WANT_INTERNALS +#endif // SRC_NODE_SQLITE_H_ diff --git a/test/parallel/test-sqlite.js b/test/parallel/test-sqlite.js new file mode 100644 index 00000000000..99c8b7ee72a --- /dev/null +++ b/test/parallel/test-sqlite.js @@ -0,0 +1,740 @@ +// Flags: --experimental-sqlite +'use strict'; +const { spawnPromisified } = require('../common'); +const tmpdir = require('../common/tmpdir'); +const { existsSync } = require('node:fs'); +const { join } = require('node:path'); +const { DatabaseSync, StatementSync } = require('node:sqlite'); +const { suite, test } = require('node:test'); +let cnt = 0; + +tmpdir.refresh(); + +function nextDb() { + return join(tmpdir.path, `database-${cnt++}.db`); +} + +suite('accessing the node:sqlite module', () => { + test('cannot be accessed without the node: scheme', (t) => { + t.assert.throws(() => { + require('sqlite'); + }, { + code: 'MODULE_NOT_FOUND', + message: /Cannot find module 'sqlite'/, + }); + }); + + test('cannot be accessed without --experimental-sqlite flag', async (t) => { + const { + stdout, + stderr, + code, + signal, + } = await spawnPromisified(process.execPath, [ + '-e', + 'require("node:sqlite")', + ]); + + t.assert.strictEqual(stdout, ''); + t.assert.match(stderr, /No such built-in module: node:sqlite/); + t.assert.notStrictEqual(code, 0); + t.assert.strictEqual(signal, null); + }); +}); + +suite('DatabaseSync() constructor', () => { + test('throws if called without new', (t) => { + t.assert.throws(() => { + DatabaseSync(); + }, { + code: 'ERR_CONSTRUCT_CALL_REQUIRED', + message: /Cannot call constructor without `new`/, + }); + }); + + test('throws if database path is not a string', (t) => { + t.assert.throws(() => { + new DatabaseSync(); + }, { + code: 'ERR_INVALID_ARG_TYPE', + message: /The "path" argument must be a string/, + }); + }); + + test('throws if options is provided but is not an object', (t) => { + t.assert.throws(() => { + new DatabaseSync('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 DatabaseSync('foo', { open: 5 }); + }, { + code: 'ERR_INVALID_ARG_TYPE', + message: /The "options\.open" argument must be a boolean/, + }); + }); +}); + +suite('DatabaseSync.prototype.open()', () => { + test('opens a database connection', (t) => { + const dbPath = nextDb(); + const db = new DatabaseSync(dbPath, { open: false }); + + t.assert.strictEqual(existsSync(dbPath), false); + t.assert.strictEqual(db.open(), undefined); + t.assert.strictEqual(existsSync(dbPath), true); + }); + + test('throws if database is already open', (t) => { + const db = new DatabaseSync(nextDb(), { open: false }); + + db.open(); + t.assert.throws(() => { + db.open(); + }, { + code: 'ERR_INVALID_STATE', + message: /database is already open/, + }); + }); +}); + +suite('DatabaseSync.prototype.close()', () => { + test('closes an open database connection', (t) => { + const db = new DatabaseSync(nextDb()); + + t.assert.strictEqual(db.close(), undefined); + }); + + test('throws if database is not open', (t) => { + const db = new DatabaseSync(nextDb(), { open: false }); + + t.assert.throws(() => { + db.close(); + }, { + code: 'ERR_INVALID_STATE', + message: /database is not open/, + }); + }); +}); + +suite('DatabaseSync.prototype.prepare()', () => { + test('returns a prepared statement', (t) => { + const db = new DatabaseSync(nextDb()); + const stmt = db.prepare('CREATE TABLE webstorage(key TEXT)'); + t.assert.ok(stmt instanceof StatementSync); + }); + + test('throws if database is not open', (t) => { + const db = new DatabaseSync(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) => { + const db = new DatabaseSync(nextDb()); + + t.assert.throws(() => { + db.prepare(); + }, { + code: 'ERR_INVALID_ARG_TYPE', + message: /The "sql" argument must be a string/, + }); + }); +}); + +suite('DatabaseSync.prototype.exec()', () => { + test('executes SQL', (t) => { + const db = new DatabaseSync(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(), [ + { key: 1, val: 2 }, + { key: 8, val: 9 }, + ]); + }); + + test('reports errors from SQLite', (t) => { + const db = new DatabaseSync(nextDb()); + + t.assert.throws(() => { + db.exec('CREATE TABLEEEE'); + }, { + code: 'ERR_SQLITE_ERROR', + message: /syntax error/, + }); + }); + + test('throws if database is not open', (t) => { + const db = new DatabaseSync(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) => { + const db = new DatabaseSync(nextDb()); + + t.assert.throws(() => { + db.exec(); + }, { + code: 'ERR_INVALID_ARG_TYPE', + message: /The "sql" argument must be a string/, + }); + }); +}); + +suite('StatementSync() constructor', () => { + test('StatementSync cannot be constructed directly', (t) => { + t.assert.throws(() => { + new StatementSync(); + }, { + code: 'ERR_ILLEGAL_CONSTRUCTOR', + message: /Illegal constructor/, + }); + }); +}); + +suite('StatementSync.prototype.get()', () => { + test('executes a query and returns undefined on no results', (t) => { + const db = new DatabaseSync(nextDb()); + const stmt = db.prepare('CREATE TABLE storage(key TEXT, val TEXT)'); + t.assert.strictEqual(stmt.get(), undefined); + }); + + test('executes a query and returns the first result', (t) => { + const db = new DatabaseSync(nextDb()); + let stmt = db.prepare('CREATE TABLE storage(key TEXT, val TEXT)'); + t.assert.strictEqual(stmt.get(), undefined); + stmt = db.prepare('INSERT INTO storage (key, val) VALUES (?, ?)'); + t.assert.strictEqual(stmt.get('key1', 'val1'), undefined); + t.assert.strictEqual(stmt.get('key2', 'val2'), undefined); + stmt = db.prepare('SELECT * FROM storage ORDER BY key'); + t.assert.deepStrictEqual(stmt.get(), { key: 'key1', val: 'val1' }); + }); +}); + +suite('StatementSync.prototype.all()', () => { + test('executes a query and returns an empty array on no results', (t) => { + const db = new DatabaseSync(nextDb()); + const stmt = db.prepare('CREATE TABLE storage(key TEXT, val TEXT)'); + t.assert.deepStrictEqual(stmt.all(), []); + }); + + test('executes a query and returns all results', (t) => { + const db = new DatabaseSync(nextDb()); + let stmt = db.prepare('CREATE TABLE storage(key TEXT, val TEXT)'); + t.assert.deepStrictEqual(stmt.run(), { changes: 0, lastInsertRowid: 0 }); + stmt = db.prepare('INSERT INTO storage (key, val) VALUES (?, ?)'); + t.assert.deepStrictEqual( + stmt.run('key1', 'val1'), + { changes: 1, lastInsertRowid: 1 }, + ); + t.assert.deepStrictEqual( + stmt.run('key2', 'val2'), + { changes: 1, lastInsertRowid: 2 }, + ); + stmt = db.prepare('SELECT * FROM storage ORDER BY key'); + t.assert.deepStrictEqual(stmt.all(), [ + { key: 'key1', val: 'val1' }, + { key: 'key2', val: 'val2' }, + ]); + }); +}); + +suite('StatementSync.prototype.run()', () => { + test('executes a query and returns change metadata', (t) => { + const db = new DatabaseSync(nextDb()); + const setup = db.exec(` + CREATE TABLE storage(key TEXT, val TEXT); + INSERT INTO storage (key, val) VALUES ('foo', 'bar'); + `); + t.assert.strictEqual(setup, undefined); + const stmt = db.prepare('SELECT * FROM storage'); + t.assert.deepStrictEqual(stmt.run(), { changes: 1, lastInsertRowid: 1 }); + }); + + test('SQLite throws when trying to bind too many parameters', (t) => { + const db = new DatabaseSync(nextDb()); + 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 (?, ?)'); + t.assert.throws(() => { + stmt.run(1, 2, 3); + }, { + code: 'ERR_SQLITE_ERROR', + message: 'column index out of range', + errcode: 25, + errstr: 'column index out of range', + }); + }); + + test('SQLite defaults to NULL for unbound parameters', (t) => { + const db = new DatabaseSync(nextDb()); + const setup = db.exec( + 'CREATE TABLE data(key INTEGER PRIMARY KEY, val INTEGER NOT NULL) STRICT;' + ); + t.assert.strictEqual(setup, undefined); + const stmt = db.prepare('INSERT INTO data (key, val) VALUES (?, ?)'); + t.assert.throws(() => { + stmt.run(1); + }, { + code: 'ERR_SQLITE_ERROR', + message: 'NOT NULL constraint failed: data.val', + errcode: 1299, + errstr: 'constraint failed', + }); + }); +}); + +suite('StatementSync.prototype.sourceSQL()', () => { + test('returns input SQL', (t) => { + const db = new DatabaseSync(nextDb()); + const setup = db.exec( + 'CREATE TABLE types(key INTEGER PRIMARY KEY, val INTEGER) STRICT;' + ); + t.assert.strictEqual(setup, undefined); + const sql = 'INSERT INTO types (key, val) VALUES ($k, $v)'; + const stmt = db.prepare(sql); + t.assert.strictEqual(stmt.sourceSQL(), sql); + }); +}); + +suite('StatementSync.prototype.expandedSQL()', () => { + test('returns expanded SQL', (t) => { + const db = new DatabaseSync(nextDb()); + const setup = db.exec( + 'CREATE TABLE types(key INTEGER PRIMARY KEY, val INTEGER) STRICT;' + ); + t.assert.strictEqual(setup, undefined); + const sql = 'INSERT INTO types (key, val) VALUES ($k, ?)'; + const expanded = 'INSERT INTO types (key, val) VALUES (\'33\', \'42\')'; + const stmt = db.prepare(sql); + t.assert.deepStrictEqual( + stmt.run({ $k: '33' }, '42'), + { changes: 1, lastInsertRowid: 33 }, + ); + t.assert.strictEqual(stmt.expandedSQL(), expanded); + }); +}); + +suite('StatementSync.prototype.setReadBigInts()', () => { + test('BigInts support can be toggled', (t) => { + const db = new DatabaseSync(nextDb()); + 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(), { val: 42 }); + t.assert.strictEqual(query.setReadBigInts(true), undefined); + t.assert.deepStrictEqual(query.get(), { val: 42n }); + t.assert.strictEqual(query.setReadBigInts(false), undefined); + t.assert.deepStrictEqual(query.get(), { val: 42 }); + + const insert = db.prepare('INSERT INTO data (key) VALUES (?)'); + t.assert.deepStrictEqual( + insert.run(10), + { changes: 1, lastInsertRowid: 10 }, + ); + t.assert.strictEqual(insert.setReadBigInts(true), undefined); + t.assert.deepStrictEqual( + insert.run(20), + { changes: 1n, lastInsertRowid: 20n }, + ); + t.assert.strictEqual(insert.setReadBigInts(false), undefined); + t.assert.deepStrictEqual( + insert.run(30), + { changes: 1, lastInsertRowid: 30 }, + ); + }); + + test('throws when input is not a boolean', (t) => { + const db = new DatabaseSync(nextDb()); + const setup = db.exec( + 'CREATE TABLE types(key INTEGER PRIMARY KEY, val INTEGER) STRICT;' + ); + t.assert.strictEqual(setup, undefined); + const stmt = db.prepare('INSERT INTO types (key, val) VALUES ($k, $v)'); + t.assert.throws(() => { + stmt.setReadBigInts(); + }, { + code: 'ERR_INVALID_ARG_TYPE', + message: /The "readBigInts" argument must be a boolean/, + }); + }); +}); + +suite('StatementSync.prototype.setAllowBareNamedParameters()', () => { + test('bare named parameter support can be toggled', (t) => { + const db = new DatabaseSync(nextDb()); + 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.deepStrictEqual( + stmt.run({ k: 1, v: 2 }), + { changes: 1, lastInsertRowid: 1 }, + ); + t.assert.strictEqual(stmt.setAllowBareNamedParameters(false), undefined); + t.assert.throws(() => { + stmt.run({ k: 2, v: 4 }); + }, { + code: 'ERR_INVALID_STATE', + message: /Unknown named parameter 'k'/, + }); + t.assert.strictEqual(stmt.setAllowBareNamedParameters(true), undefined); + t.assert.deepStrictEqual( + stmt.run({ k: 3, v: 6 }), + { changes: 1, lastInsertRowid: 3 }, + ); + }); + + test('throws when input is not a boolean', (t) => { + const db = new DatabaseSync(nextDb()); + 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.setAllowBareNamedParameters(); + }, { + code: 'ERR_INVALID_ARG_TYPE', + message: /The "allowBareNamedParameters" argument must be a boolean/, + }); + }); +}); + +suite('data binding and mapping', () => { + test('supported data types', (t) => { + const u8a = new TextEncoder().encode('a☃b☃c'); + const db = new DatabaseSync(nextDb()); + const setup = db.exec(` + CREATE TABLE types( + key INTEGER PRIMARY KEY, + int INTEGER, + double REAL, + text TEXT, + buf BLOB + ) STRICT; + `); + t.assert.strictEqual(setup, undefined); + const stmt = db.prepare('INSERT INTO types (key, int, double, text, buf) ' + + 'VALUES (?, ?, ?, ?, ?)'); + t.assert.deepStrictEqual( + stmt.run(1, 42, 3.14159, 'foo', u8a), + { changes: 1, lastInsertRowid: 1 }, + ); + t.assert.deepStrictEqual( + stmt.run(2, null, null, null, null), + { changes: 1, lastInsertRowid: 2 } + ); + t.assert.deepStrictEqual( + stmt.run(3, Number(8), Number(2.718), String('bar'), Buffer.from('x☃y☃')), + { changes: 1, lastInsertRowid: 3 }, + ); + t.assert.deepStrictEqual( + stmt.run(4, 99n, 0xf, '', new Uint8Array()), + { changes: 1, lastInsertRowid: 4 }, + ); + + const query = db.prepare('SELECT * FROM types WHERE key = ?'); + t.assert.deepStrictEqual(query.get(1), { + key: 1, + int: 42, + double: 3.14159, + text: 'foo', + buf: u8a, + }); + t.assert.deepStrictEqual(query.get(2), { + key: 2, + int: null, + double: null, + text: null, + buf: null, + }); + t.assert.deepStrictEqual(query.get(3), { + key: 3, + int: 8, + double: 2.718, + text: 'bar', + buf: new TextEncoder().encode('x☃y☃'), + }); + t.assert.deepStrictEqual(query.get(4), { + key: 4, + int: 99, + double: 0xf, + text: '', + buf: new Uint8Array(), + }); + }); + + test('unsupported data types', (t) => { + const db = new DatabaseSync(nextDb()); + const setup = db.exec( + 'CREATE TABLE types(key INTEGER PRIMARY KEY, val INTEGER) STRICT;' + ); + t.assert.strictEqual(setup, undefined); + + [ + undefined, + () => {}, + Symbol(), + /foo/, + Promise.resolve(), + new Map(), + new Set(), + ].forEach((val) => { + t.assert.throws(() => { + db.prepare('INSERT INTO types (key, val) VALUES (?, ?)').run(1, val); + }, { + code: 'ERR_INVALID_ARG_TYPE', + message: /Provided value cannot be bound to SQLite parameter 2/, + }); + }); + + t.assert.throws(() => { + const stmt = db.prepare('INSERT INTO types (key, val) VALUES ($k, $v)'); + stmt.run({ $k: 1, $v: () => {} }); + }, { + code: 'ERR_INVALID_ARG_TYPE', + message: /Provided value cannot be bound to SQLite parameter 2/, + }); + }); + + test('throws when binding a BigInt that is too large', (t) => { + const max = 9223372036854775807n; // Largest 64-bit signed integer value. + const db = new DatabaseSync(nextDb()); + const setup = db.exec( + 'CREATE TABLE types(key INTEGER PRIMARY KEY, val INTEGER) STRICT;' + ); + t.assert.strictEqual(setup, undefined); + const stmt = db.prepare('INSERT INTO types (key, val) VALUES (?, ?)'); + t.assert.deepStrictEqual( + stmt.run(1, max), + { changes: 1, lastInsertRowid: 1 }, + ); + t.assert.throws(() => { + stmt.run(1, max + 1n); + }, { + code: 'ERR_INVALID_ARG_VALUE', + message: /BigInt value is too large to bind/, + }); + }); + + test('statements are unbound on each call', (t) => { + const db = new DatabaseSync(nextDb()); + 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 (?, ?)'); + t.assert.deepStrictEqual( + stmt.run(1, 5), + { changes: 1, lastInsertRowid: 1 }, + ); + t.assert.deepStrictEqual( + stmt.run(), + { changes: 1, lastInsertRowid: 2 }, + ); + t.assert.deepStrictEqual( + db.prepare('SELECT * FROM data ORDER BY key').all(), + [{ key: 1, val: 5 }, { key: 2, val: null }], + ); + }); +}); + +suite('manual transactions', () => { + test('a transaction is committed', (t) => { + const db = new DatabaseSync(nextDb()); + const setup = db.exec(` + CREATE TABLE data( + key INTEGER PRIMARY KEY + ) STRICT; + `); + t.assert.strictEqual(setup, undefined); + t.assert.deepStrictEqual( + db.prepare('BEGIN').run(), + { changes: 0, lastInsertRowid: 0 }, + ); + t.assert.deepStrictEqual( + db.prepare('INSERT INTO data (key) VALUES (100)').run(), + { changes: 1, lastInsertRowid: 100 }, + ); + t.assert.deepStrictEqual( + db.prepare('COMMIT').run(), + { changes: 1, lastInsertRowid: 100 }, + ); + t.assert.deepStrictEqual( + db.prepare('SELECT * FROM data').all(), + [{ key: 100 }], + ); + }); + + test('a transaction is rolled back', (t) => { + const db = new DatabaseSync(nextDb()); + const setup = db.exec(` + CREATE TABLE data( + key INTEGER PRIMARY KEY + ) STRICT; + `); + t.assert.strictEqual(setup, undefined); + t.assert.deepStrictEqual( + db.prepare('BEGIN').run(), + { changes: 0, lastInsertRowid: 0 }, + ); + t.assert.deepStrictEqual( + db.prepare('INSERT INTO data (key) VALUES (100)').run(), + { changes: 1, lastInsertRowid: 100 }, + ); + t.assert.deepStrictEqual( + db.prepare('ROLLBACK').run(), + { changes: 1, lastInsertRowid: 100 }, + ); + t.assert.deepStrictEqual(db.prepare('SELECT * FROM data').all(), []); + }); +}); + +suite('named parameters', () => { + test('throws on unknown named parameters', (t) => { + const db = new DatabaseSync(nextDb()); + const setup = db.exec( + 'CREATE TABLE types(key INTEGER PRIMARY KEY, val INTEGER) STRICT;' + ); + t.assert.strictEqual(setup, undefined); + + t.assert.throws(() => { + const stmt = db.prepare('INSERT INTO types (key, val) VALUES ($k, $v)'); + stmt.run({ $k: 1, $unknown: 1 }); + }, { + code: 'ERR_INVALID_STATE', + message: /Unknown named parameter '\$unknown'/, + }); + }); + + test('bare named parameters are supported', (t) => { + const db = new DatabaseSync(nextDb()); + 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)'); + stmt.run({ k: 1, v: 9 }); + t.assert.deepStrictEqual( + db.prepare('SELECT * FROM data').get(), + { key: 1, val: 9 }, + ); + }); + + test('duplicate bare named parameters are supported', (t) => { + const db = new DatabaseSync(nextDb()); + 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, $k)'); + stmt.run({ k: 1 }); + t.assert.deepStrictEqual( + db.prepare('SELECT * FROM data').get(), + { key: 1, val: 1 }, + ); + }); + + test('bare named parameters throw on ambiguous names', (t) => { + const db = new DatabaseSync(nextDb()); + const setup = db.exec( + 'CREATE TABLE types(key INTEGER PRIMARY KEY, val INTEGER) STRICT;' + ); + t.assert.strictEqual(setup, undefined); + const stmt = db.prepare('INSERT INTO types (key, val) VALUES ($k, @k)'); + t.assert.throws(() => { + stmt.run({ k: 1 }); + }, { + code: 'ERR_INVALID_STATE', + message: 'Cannot create bare named parameter \'k\' because of ' + + 'conflicting names \'$k\' and \'@k\'.', + }); + }); +}); + +test('ERR_SQLITE_ERROR is thrown for errors originating from SQLite', (t) => { + const db = new DatabaseSync(nextDb()); + const setup = db.exec(` + CREATE TABLE test( + key INTEGER PRIMARY KEY + ) STRICT; + `); + t.assert.strictEqual(setup, undefined); + const stmt = db.prepare('INSERT INTO test (key) VALUES (?)'); + t.assert.deepStrictEqual(stmt.run(1), { changes: 1, lastInsertRowid: 1 }); + t.assert.throws(() => { + stmt.run(1); + }, { + code: 'ERR_SQLITE_ERROR', + message: 'UNIQUE constraint failed: test.key', + errcode: 1555, + errstr: 'constraint failed', + }); +}); + +test('in-memory databases are supported', (t) => { + const db1 = new DatabaseSync(':memory:'); + const db2 = new DatabaseSync(':memory:'); + const setup1 = db1.exec(` + CREATE TABLE data(key INTEGER PRIMARY KEY); + INSERT INTO data (key) VALUES (1); + `); + const setup2 = db2.exec(` + CREATE TABLE data(key INTEGER PRIMARY KEY); + INSERT INTO data (key) VALUES (1); + `); + t.assert.strictEqual(setup1, undefined); + t.assert.strictEqual(setup2, undefined); + t.assert.deepStrictEqual( + db1.prepare('SELECT * FROM data').all(), + [{ key: 1 }] + ); + t.assert.deepStrictEqual( + db2.prepare('SELECT * FROM data').all(), + [{ key: 1 }] + ); +}); + +test('PRAGMAs are supported', (t) => { + const db = new DatabaseSync(nextDb()); + t.assert.deepStrictEqual( + db.prepare('PRAGMA journal_mode = WAL').get(), + { journal_mode: 'wal' }, + ); + t.assert.deepStrictEqual( + db.prepare('PRAGMA journal_mode').get(), + { journal_mode: 'wal' }, + ); +}); diff --git a/tools/doc/type-parser.mjs b/tools/doc/type-parser.mjs index 4e962e1c1ec..7d5389e2edb 100644 --- a/tools/doc/type-parser.mjs +++ b/tools/doc/type-parser.mjs @@ -199,6 +199,8 @@ const customTypesMap = { 'repl.REPLServer': 'repl.html#class-replserver', + 'StatementSync': 'sqlite.html#class-statementsync', + 'Stream': 'stream.html#stream', 'stream.Duplex': 'stream.html#class-streamduplex', 'Duplex': 'stream.html#class-streamduplex',