sqlite,test,doc: allow Buffer and URL as database location

PR-URL: https://github.com/nodejs/node/pull/56991
Reviewed-By: Colin Ihrig <cjihrig@gmail.com>
This commit is contained in:
Edy Silva 2025-02-27 14:43:13 -03:00 committed by GitHub
parent 269c851240
commit c7cf6778c7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 304 additions and 37 deletions

View File

@ -77,20 +77,24 @@ console.log(query.all());
<!-- YAML <!-- YAML
added: v22.5.0 added: v22.5.0
changes:
- version: REPLACEME
pr-url: https://github.com/nodejs/node/pull/56991
description: The `path` argument now supports Buffer and URL objects.
--> -->
This class represents a single [connection][] to a SQLite database. All APIs This class represents a single [connection][] to a SQLite database. All APIs
exposed by this class execute synchronously. exposed by this class execute synchronously.
### `new DatabaseSync(location[, options])` ### `new DatabaseSync(path[, options])`
<!-- YAML <!-- YAML
added: v22.5.0 added: v22.5.0
--> -->
* `location` {string} The location of the database. A SQLite database can be * `path` {string | Buffer | URL} The path of the database. A SQLite database can be
stored in a file or completely [in memory][]. To use a file-backed database, 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 the path should be a file path. To use an in-memory database, the path
should be the special name `':memory:'`. should be the special name `':memory:'`.
* `options` {Object} Configuration options for the database connection. The * `options` {Object} Configuration options for the database connection. The
following options are supported: following options are supported:
@ -200,7 +204,7 @@ wrapper around [`sqlite3_create_function_v2()`][].
added: v22.5.0 added: v22.5.0
--> -->
Opens the database specified in the `location` argument of the `DatabaseSync` Opens the database specified in the `path` argument of the `DatabaseSync`
constructor. This method should only be used when the database is not opened via 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. the constructor. An exception is thrown if the database is already open.
@ -534,15 +538,19 @@ exception.
| `TEXT` | {string} | | `TEXT` | {string} |
| `BLOB` | {TypedArray} or {DataView} | | `BLOB` | {TypedArray} or {DataView} |
## `sqlite.backup(sourceDb, destination[, options])` ## `sqlite.backup(sourceDb, path[, options])`
<!-- YAML <!-- YAML
added: v23.8.0 added: v23.8.0
changes:
- version: REPLACEME
pr-url: https://github.com/nodejs/node/pull/56991
description: The `path` argument now supports Buffer and URL objects.
--> -->
* `sourceDb` {DatabaseSync} The database to backup. The source database must be open. * `sourceDb` {DatabaseSync} The database to backup. The source database must be open.
* `destination` {string} The path where the backup will be created. If the file already exists, the contents will be * `path` {string | Buffer | URL} The path where the backup will be created. If the file already exists,
overwritten. the contents will be overwritten.
* `options` {Object} Optional configuration for the backup. The * `options` {Object} Optional configuration for the backup. The
following properties are supported: following properties are supported:
* `source` {string} Name of the source database. This can be `'main'` (the default primary database) or any other * `source` {string} Name of the source database. This can be `'main'` (the default primary database) or any other

View File

@ -194,6 +194,7 @@
V(host_string, "host") \ V(host_string, "host") \
V(hostmaster_string, "hostmaster") \ V(hostmaster_string, "hostmaster") \
V(hostname_string, "hostname") \ V(hostname_string, "hostname") \
V(href_string, "href") \
V(http_1_1_string, "http/1.1") \ V(http_1_1_string, "http/1.1") \
V(id_string, "id") \ V(id_string, "id") \
V(identity_string, "identity") \ V(identity_string, "identity") \

View File

@ -7,6 +7,7 @@
#include "node.h" #include "node.h"
#include "node_errors.h" #include "node_errors.h"
#include "node_mem-inl.h" #include "node_mem-inl.h"
#include "node_url.h"
#include "sqlite3.h" #include "sqlite3.h"
#include "threadpoolwork-inl.h" #include "threadpoolwork-inl.h"
#include "util-inl.h" #include "util-inl.h"
@ -181,10 +182,11 @@ class BackupJob : public ThreadPoolWork {
void ScheduleBackup() { void ScheduleBackup() {
Isolate* isolate = env()->isolate(); Isolate* isolate = env()->isolate();
HandleScope handle_scope(isolate); HandleScope handle_scope(isolate);
backup_status_ = sqlite3_open_v2(destination_name_.c_str(), backup_status_ = sqlite3_open_v2(
&dest_, destination_name_.c_str(),
SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE, &dest_,
nullptr); SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE | SQLITE_OPEN_URI,
nullptr);
Local<Promise::Resolver> resolver = Local<Promise::Resolver> resolver =
Local<Promise::Resolver>::New(env()->isolate(), resolver_); Local<Promise::Resolver>::New(env()->isolate(), resolver_);
if (backup_status_ != SQLITE_OK) { if (backup_status_ != SQLITE_OK) {
@ -503,11 +505,14 @@ bool DatabaseSync::Open() {
} }
// TODO(cjihrig): Support additional flags. // TODO(cjihrig): Support additional flags.
int default_flags = SQLITE_OPEN_URI;
int flags = open_config_.get_read_only() int flags = open_config_.get_read_only()
? SQLITE_OPEN_READONLY ? SQLITE_OPEN_READONLY
: SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE; : SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE;
int r = sqlite3_open_v2( int r = sqlite3_open_v2(open_config_.location().c_str(),
open_config_.location().c_str(), &connection_, flags, nullptr); &connection_,
flags | default_flags,
nullptr);
CHECK_ERROR_OR_THROW(env()->isolate(), this, r, SQLITE_OK, false); CHECK_ERROR_OR_THROW(env()->isolate(), this, r, SQLITE_OK, false);
r = sqlite3_db_config(connection_, r = sqlite3_db_config(connection_,
@ -585,27 +590,85 @@ bool DatabaseSync::ShouldIgnoreSQLiteError() {
return ignore_next_sqlite_error_; return ignore_next_sqlite_error_;
} }
std::optional<std::string> ValidateDatabasePath(Environment* env,
Local<Value> path,
const std::string& field_name) {
auto has_null_bytes = [](const std::string& str) {
return str.find('\0') != std::string::npos;
};
std::string location;
if (path->IsString()) {
location = Utf8Value(env->isolate(), path.As<String>()).ToString();
if (!has_null_bytes(location)) {
return location;
}
}
if (path->IsUint8Array()) {
Local<Uint8Array> buffer = path.As<Uint8Array>();
size_t byteOffset = buffer->ByteOffset();
size_t byteLength = buffer->ByteLength();
auto data =
static_cast<const uint8_t*>(buffer->Buffer()->Data()) + byteOffset;
if (!(std::find(data, data + byteLength, 0) != data + byteLength)) {
Local<Value> out;
if (String::NewFromUtf8(env->isolate(),
reinterpret_cast<const char*>(data),
NewStringType::kNormal,
static_cast<int>(byteLength))
.ToLocal(&out)) {
return Utf8Value(env->isolate(), out.As<String>()).ToString();
}
}
}
// When is URL
if (path->IsObject()) {
Local<Object> url = path.As<Object>();
Local<Value> href;
Local<Value> protocol;
if (url->Get(env->context(), env->href_string()).ToLocal(&href) &&
href->IsString() &&
url->Get(env->context(), env->protocol_string()).ToLocal(&protocol) &&
protocol->IsString()) {
location = Utf8Value(env->isolate(), href.As<String>()).ToString();
if (!has_null_bytes(location)) {
auto file_url = ada::parse(location);
CHECK(file_url);
if (file_url->type != ada::scheme::FILE) {
THROW_ERR_INVALID_URL_SCHEME(env->isolate());
return std::nullopt;
}
return location;
}
}
}
THROW_ERR_INVALID_ARG_TYPE(env->isolate(),
"The \"%s\" argument must be a string, "
"Uint8Array, or URL without null bytes.",
field_name.c_str());
return std::nullopt;
}
void DatabaseSync::New(const FunctionCallbackInfo<Value>& args) { void DatabaseSync::New(const FunctionCallbackInfo<Value>& args) {
Environment* env = Environment::GetCurrent(args); Environment* env = Environment::GetCurrent(args);
if (!args.IsConstructCall()) { if (!args.IsConstructCall()) {
THROW_ERR_CONSTRUCT_CALL_REQUIRED(env); THROW_ERR_CONSTRUCT_CALL_REQUIRED(env);
return; return;
} }
if (!args[0]->IsString()) { std::optional<std::string> location =
THROW_ERR_INVALID_ARG_TYPE(env->isolate(), ValidateDatabasePath(env, args[0], "path");
"The \"path\" argument must be a string."); if (!location.has_value()) {
return; return;
} }
std::string location = DatabaseOpenConfiguration open_config(std::move(location.value()));
Utf8Value(env->isolate(), args[0].As<String>()).ToString();
DatabaseOpenConfiguration open_config(std::move(location));
bool open = true; bool open = true;
bool allow_load_extension = false; bool allow_load_extension = false;
if (args.Length() > 1) { if (args.Length() > 1) {
if (!args[1]->IsObject()) { if (!args[1]->IsObject()) {
THROW_ERR_INVALID_ARG_TYPE(env->isolate(), THROW_ERR_INVALID_ARG_TYPE(env->isolate(),
@ -984,17 +1047,15 @@ void Backup(const FunctionCallbackInfo<Value>& args) {
DatabaseSync* db; DatabaseSync* db;
ASSIGN_OR_RETURN_UNWRAP(&db, args[0].As<Object>()); ASSIGN_OR_RETURN_UNWRAP(&db, args[0].As<Object>());
THROW_AND_RETURN_ON_BAD_STATE(env, !db->IsOpen(), "database is not open"); THROW_AND_RETURN_ON_BAD_STATE(env, !db->IsOpen(), "database is not open");
if (!args[1]->IsString()) { std::optional<std::string> dest_path =
THROW_ERR_INVALID_ARG_TYPE( ValidateDatabasePath(env, args[1], "path");
env->isolate(), "The \"destination\" argument must be a string."); if (!dest_path.has_value()) {
return; return;
} }
int rate = 100; int rate = 100;
std::string source_db = "main"; std::string source_db = "main";
std::string dest_db = "main"; std::string dest_db = "main";
Utf8Value dest_path(env->isolate(), args[1].As<String>());
Local<Function> progressFunc = Local<Function>(); Local<Function> progressFunc = Local<Function>();
if (args.Length() > 2) { if (args.Length() > 2) {
@ -1077,12 +1138,11 @@ void Backup(const FunctionCallbackInfo<Value>& args) {
} }
args.GetReturnValue().Set(resolver->GetPromise()); args.GetReturnValue().Set(resolver->GetPromise());
BackupJob* job = new BackupJob(env, BackupJob* job = new BackupJob(env,
db, db,
resolver, resolver,
std::move(source_db), std::move(source_db),
*dest_path, dest_path.value(),
std::move(dest_db), std::move(dest_db),
rate, rate,
progressFunc); progressFunc);

View File

@ -4,6 +4,7 @@ import { join } from 'node:path';
import { backup, DatabaseSync } from 'node:sqlite'; import { backup, DatabaseSync } from 'node:sqlite';
import { describe, test } from 'node:test'; import { describe, test } from 'node:test';
import { writeFileSync } from 'node:fs'; import { writeFileSync } from 'node:fs';
import { pathToFileURL } from 'node:url';
let cnt = 0; let cnt = 0;
@ -13,8 +14,8 @@ function nextDb() {
return join(tmpdir.path, `database-${cnt++}.db`); return join(tmpdir.path, `database-${cnt++}.db`);
} }
function makeSourceDb() { function makeSourceDb(dbPath = ':memory:') {
const database = new DatabaseSync(':memory:'); const database = new DatabaseSync(dbPath);
database.exec(` database.exec(`
CREATE TABLE data( CREATE TABLE data(
@ -42,21 +43,39 @@ describe('backup()', () => {
}); });
}); });
test('throws if path is not a string', (t) => { test('throws if path is not a string, URL, or Buffer', (t) => {
const database = makeSourceDb(); const database = makeSourceDb();
t.assert.throws(() => { t.assert.throws(() => {
backup(database); backup(database);
}, { }, {
code: 'ERR_INVALID_ARG_TYPE', code: 'ERR_INVALID_ARG_TYPE',
message: 'The "destination" argument must be a string.' message: 'The "path" argument must be a string, Uint8Array, or URL without null bytes.'
}); });
t.assert.throws(() => { t.assert.throws(() => {
backup(database, {}); backup(database, {});
}, { }, {
code: 'ERR_INVALID_ARG_TYPE', code: 'ERR_INVALID_ARG_TYPE',
message: 'The "destination" argument must be a string.' message: 'The "path" argument must be a string, Uint8Array, or URL without null bytes.'
});
});
test('throws if the database path contains null bytes', (t) => {
const database = makeSourceDb();
t.assert.throws(() => {
backup(database, Buffer.from('l\0cation'));
}, {
code: 'ERR_INVALID_ARG_TYPE',
message: 'The "path" argument must be a string, Uint8Array, or URL without null bytes.'
});
t.assert.throws(() => {
backup(database, 'l\0cation');
}, {
code: 'ERR_INVALID_ARG_TYPE',
message: 'The "path" argument must be a string, Uint8Array, or URL without null bytes.'
}); });
}); });
@ -141,6 +160,46 @@ test('database backup', async (t) => {
}); });
}); });
test('backup database using location as URL', async (t) => {
const database = makeSourceDb();
const destDb = pathToFileURL(nextDb());
t.after(() => { database.close(); });
await backup(database, destDb);
const backupDb = new DatabaseSync(destDb);
t.after(() => { backupDb.close(); });
const rows = backupDb.prepare('SELECT * FROM data').all();
t.assert.deepStrictEqual(rows, [
{ __proto__: null, key: 1, value: 'value-1' },
{ __proto__: null, key: 2, value: 'value-2' },
]);
});
test('backup database using location as Buffer', async (t) => {
const database = makeSourceDb();
const destDb = Buffer.from(nextDb());
t.after(() => { database.close(); });
await backup(database, destDb);
const backupDb = new DatabaseSync(destDb);
t.after(() => { backupDb.close(); });
const rows = backupDb.prepare('SELECT * FROM data').all();
t.assert.deepStrictEqual(rows, [
{ __proto__: null, key: 1, value: 'value-1' },
{ __proto__: null, key: 2, value: 'value-2' },
]);
});
test('database backup in a single call', async (t) => { test('database backup in a single call', async (t) => {
const progressFn = t.mock.fn(); const progressFn = t.mock.fn();
const database = makeSourceDb(); const database = makeSourceDb();
@ -179,6 +238,19 @@ test('throws exception when trying to start backup from a closed database', (t)
}); });
}); });
test('throws if URL is not file: scheme', (t) => {
const database = new DatabaseSync(':memory:');
t.after(() => { database.close(); });
t.assert.throws(() => {
backup(database, new URL('http://example.com/backup.db'));
}, {
code: 'ERR_INVALID_URL_SCHEME',
message: 'The URL must be of scheme file:',
});
});
test('database backup fails when dest file is not writable', async (t) => { test('database backup fails when dest file is not writable', async (t) => {
const readonlyDestDb = nextDb(); const readonlyDestDb = nextDb();
writeFileSync(readonlyDestDb, '', { mode: 0o444 }); writeFileSync(readonlyDestDb, '', { mode: 0o444 });
@ -225,7 +297,7 @@ test('backup fails when source db is invalid', async (t) => {
}); });
}); });
test('backup fails when destination cannot be opened', async (t) => { test('backup fails when path cannot be opened', async (t) => {
const database = makeSourceDb(); const database = makeSourceDb();
await t.assert.rejects(async () => { await t.assert.rejects(async () => {

View File

@ -23,12 +23,30 @@ suite('DatabaseSync() constructor', () => {
}); });
}); });
test('throws if database path is not a string', (t) => { test('throws if database path is not a string, Uint8Array, or URL', (t) => {
t.assert.throws(() => { t.assert.throws(() => {
new DatabaseSync(); new DatabaseSync();
}, { }, {
code: 'ERR_INVALID_ARG_TYPE', code: 'ERR_INVALID_ARG_TYPE',
message: /The "path" argument must be a string/, 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 DatabaseSync(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 DatabaseSync('l\0cation');
}, {
code: 'ERR_INVALID_ARG_TYPE',
message: 'The "path" argument must be a string, Uint8Array, or URL without null bytes.',
}); });
}); });
@ -256,6 +274,15 @@ suite('DatabaseSync.prototype.exec()', () => {
}); });
}); });
test('throws if the URL does not have the file: scheme', (t) => {
t.assert.throws(() => {
new DatabaseSync(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) => { test('throws if database is not open', (t) => {
const db = new DatabaseSync(nextDb(), { open: false }); const db = new DatabaseSync(nextDb(), { open: false });

View File

@ -4,6 +4,7 @@ const tmpdir = require('../common/tmpdir');
const { join } = require('node:path'); const { join } = require('node:path');
const { DatabaseSync, constants } = require('node:sqlite'); const { DatabaseSync, constants } = require('node:sqlite');
const { suite, test } = require('node:test'); const { suite, test } = require('node:test');
const { pathToFileURL } = require('node:url');
let cnt = 0; let cnt = 0;
tmpdir.refresh(); tmpdir.refresh();
@ -111,3 +112,101 @@ test('math functions are enabled', (t) => {
{ __proto__: null, pi: 3.141592653589793 }, { __proto__: null, pi: 3.141592653589793 },
); );
}); });
test('Buffer is supported as the database path', (t) => {
const db = new DatabaseSync(Buffer.from(nextDb()));
t.after(() => { db.close(); });
db.exec(`
CREATE TABLE data(key INTEGER PRIMARY KEY);
INSERT INTO data (key) VALUES (1);
`);
t.assert.deepStrictEqual(
db.prepare('SELECT * FROM data').all(),
[{ __proto__: null, key: 1 }]
);
});
test('URL is supported as the database path', (t) => {
const url = pathToFileURL(nextDb());
const db = new DatabaseSync(url);
t.after(() => { db.close(); });
db.exec(`
CREATE TABLE data(key INTEGER PRIMARY KEY);
INSERT INTO data (key) VALUES (1);
`);
t.assert.deepStrictEqual(
db.prepare('SELECT * FROM data').all(),
[{ __proto__: null, key: 1 }]
);
});
suite('URI query params', () => {
const baseDbPath = nextDb();
const baseDb = new DatabaseSync(baseDbPath);
baseDb.exec(`
CREATE TABLE data(key INTEGER PRIMARY KEY);
INSERT INTO data (key) VALUES (1);
`);
baseDb.close();
test('query params are supported with URL objects', (t) => {
const url = pathToFileURL(baseDbPath);
url.searchParams.set('mode', 'ro');
const readOnlyDB = new DatabaseSync(url);
t.after(() => { readOnlyDB.close(); });
t.assert.deepStrictEqual(
readOnlyDB.prepare('SELECT * FROM data').all(),
[{ __proto__: null, key: 1 }]
);
t.assert.throws(() => {
readOnlyDB.exec('INSERT INTO data (key) VALUES (1);');
}, {
code: 'ERR_SQLITE_ERROR',
message: 'attempt to write a readonly database',
});
});
test('query params are supported with string', (t) => {
const url = pathToFileURL(baseDbPath);
url.searchParams.set('mode', 'ro');
// Ensures a valid URI passed as a string is supported
const readOnlyDB = new DatabaseSync(url.toString());
t.after(() => { readOnlyDB.close(); });
t.assert.deepStrictEqual(
readOnlyDB.prepare('SELECT * FROM data').all(),
[{ __proto__: null, key: 1 }]
);
t.assert.throws(() => {
readOnlyDB.exec('INSERT INTO data (key) VALUES (1);');
}, {
code: 'ERR_SQLITE_ERROR',
message: 'attempt to write a readonly database',
});
});
test('query params are supported with Buffer', (t) => {
const url = pathToFileURL(baseDbPath);
url.searchParams.set('mode', 'ro');
// Ensures a valid URI passed as a Buffer is supported
const readOnlyDB = new DatabaseSync(Buffer.from(url.toString()));
t.after(() => { readOnlyDB.close(); });
t.assert.deepStrictEqual(
readOnlyDB.prepare('SELECT * FROM data').all(),
[{ __proto__: null, key: 1 }]
);
t.assert.throws(() => {
readOnlyDB.exec('INSERT INTO data (key) VALUES (1);');
}, {
code: 'ERR_SQLITE_ERROR',
message: 'attempt to write a readonly database',
});
});
});