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
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
exposed by this class execute synchronously.
### `new DatabaseSync(location[, options])`
### `new DatabaseSync(path[, options])`
<!-- YAML
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,
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:'`.
* `options` {Object} Configuration options for the database connection. The
following options are supported:
@ -200,7 +204,7 @@ wrapper around [`sqlite3_create_function_v2()`][].
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
the constructor. An exception is thrown if the database is already open.
@ -534,15 +538,19 @@ exception.
| `TEXT` | {string} |
| `BLOB` | {TypedArray} or {DataView} |
## `sqlite.backup(sourceDb, destination[, options])`
## `sqlite.backup(sourceDb, path[, options])`
<!-- YAML
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.
* `destination` {string} The path where the backup will be created. If the file already exists, the contents will be
overwritten.
* `path` {string | Buffer | URL} The path where the backup will be created. If the file already exists,
the contents will be overwritten.
* `options` {Object} Optional configuration for the backup. The
following properties are supported:
* `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(hostmaster_string, "hostmaster") \
V(hostname_string, "hostname") \
V(href_string, "href") \
V(http_1_1_string, "http/1.1") \
V(id_string, "id") \
V(identity_string, "identity") \

View File

@ -7,6 +7,7 @@
#include "node.h"
#include "node_errors.h"
#include "node_mem-inl.h"
#include "node_url.h"
#include "sqlite3.h"
#include "threadpoolwork-inl.h"
#include "util-inl.h"
@ -181,10 +182,11 @@ class BackupJob : public ThreadPoolWork {
void ScheduleBackup() {
Isolate* isolate = env()->isolate();
HandleScope handle_scope(isolate);
backup_status_ = sqlite3_open_v2(destination_name_.c_str(),
&dest_,
SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE,
nullptr);
backup_status_ = sqlite3_open_v2(
destination_name_.c_str(),
&dest_,
SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE | SQLITE_OPEN_URI,
nullptr);
Local<Promise::Resolver> resolver =
Local<Promise::Resolver>::New(env()->isolate(), resolver_);
if (backup_status_ != SQLITE_OK) {
@ -503,11 +505,14 @@ bool DatabaseSync::Open() {
}
// TODO(cjihrig): Support additional flags.
int default_flags = SQLITE_OPEN_URI;
int flags = open_config_.get_read_only()
? SQLITE_OPEN_READONLY
: SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE;
int r = sqlite3_open_v2(
open_config_.location().c_str(), &connection_, flags, nullptr);
int r = sqlite3_open_v2(open_config_.location().c_str(),
&connection_,
flags | default_flags,
nullptr);
CHECK_ERROR_OR_THROW(env()->isolate(), this, r, SQLITE_OK, false);
r = sqlite3_db_config(connection_,
@ -585,27 +590,85 @@ bool DatabaseSync::ShouldIgnoreSQLiteError() {
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) {
Environment* env = Environment::GetCurrent(args);
if (!args.IsConstructCall()) {
THROW_ERR_CONSTRUCT_CALL_REQUIRED(env);
return;
}
if (!args[0]->IsString()) {
THROW_ERR_INVALID_ARG_TYPE(env->isolate(),
"The \"path\" argument must be a string.");
std::optional<std::string> location =
ValidateDatabasePath(env, args[0], "path");
if (!location.has_value()) {
return;
}
std::string location =
Utf8Value(env->isolate(), args[0].As<String>()).ToString();
DatabaseOpenConfiguration open_config(std::move(location));
DatabaseOpenConfiguration open_config(std::move(location.value()));
bool open = true;
bool allow_load_extension = false;
if (args.Length() > 1) {
if (!args[1]->IsObject()) {
THROW_ERR_INVALID_ARG_TYPE(env->isolate(),
@ -984,17 +1047,15 @@ void Backup(const FunctionCallbackInfo<Value>& args) {
DatabaseSync* db;
ASSIGN_OR_RETURN_UNWRAP(&db, args[0].As<Object>());
THROW_AND_RETURN_ON_BAD_STATE(env, !db->IsOpen(), "database is not open");
if (!args[1]->IsString()) {
THROW_ERR_INVALID_ARG_TYPE(
env->isolate(), "The \"destination\" argument must be a string.");
std::optional<std::string> dest_path =
ValidateDatabasePath(env, args[1], "path");
if (!dest_path.has_value()) {
return;
}
int rate = 100;
std::string source_db = "main";
std::string dest_db = "main";
Utf8Value dest_path(env->isolate(), args[1].As<String>());
Local<Function> progressFunc = Local<Function>();
if (args.Length() > 2) {
@ -1077,12 +1138,11 @@ void Backup(const FunctionCallbackInfo<Value>& args) {
}
args.GetReturnValue().Set(resolver->GetPromise());
BackupJob* job = new BackupJob(env,
db,
resolver,
std::move(source_db),
*dest_path,
dest_path.value(),
std::move(dest_db),
rate,
progressFunc);

View File

@ -4,6 +4,7 @@ import { join } from 'node:path';
import { backup, DatabaseSync } from 'node:sqlite';
import { describe, test } from 'node:test';
import { writeFileSync } from 'node:fs';
import { pathToFileURL } from 'node:url';
let cnt = 0;
@ -13,8 +14,8 @@ function nextDb() {
return join(tmpdir.path, `database-${cnt++}.db`);
}
function makeSourceDb() {
const database = new DatabaseSync(':memory:');
function makeSourceDb(dbPath = ':memory:') {
const database = new DatabaseSync(dbPath);
database.exec(`
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();
t.assert.throws(() => {
backup(database);
}, {
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(() => {
backup(database, {});
}, {
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) => {
const progressFn = t.mock.fn();
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) => {
const readonlyDestDb = nextDb();
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();
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(() => {
new DatabaseSync();
}, {
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) => {
const db = new DatabaseSync(nextDb(), { open: false });

View File

@ -4,6 +4,7 @@ const tmpdir = require('../common/tmpdir');
const { join } = require('node:path');
const { DatabaseSync, constants } = require('node:sqlite');
const { suite, test } = require('node:test');
const { pathToFileURL } = require('node:url');
let cnt = 0;
tmpdir.refresh();
@ -111,3 +112,101 @@ test('math functions are enabled', (t) => {
{ __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',
});
});
});