From 6cb2df1713d3b7baa1d49c840beef86079cb7405 Mon Sep 17 00:00:00 2001 From: Matt Skelley Date: Thu, 22 Jan 2026 18:34:51 +0800 Subject: [PATCH 1/2] sqlite: add support for reading NULL as undefined Add a statement-level flag to control NULL conversion. Expose setReadNullAsUndefined() on StatementSync. Apply to row-reading paths. Fixes: https://github.com/nodejs/node/issues/59457 --- src/node_sqlite.cc | 102 +++++++++++++++++++++++++++++++++++++++------ src/node_sqlite.h | 24 +++++++++-- 2 files changed, 111 insertions(+), 15 deletions(-) diff --git a/src/node_sqlite.cc b/src/node_sqlite.cc index 4fad22c618900d..2a7726e5c16d52 100644 --- a/src/node_sqlite.cc +++ b/src/node_sqlite.cc @@ -121,6 +121,56 @@ using v8::Value; } \ } while (0) + #define SQLITE_VALUE_TO_JS_READ(from, isolate, use_big_int_args, \ + read_null_as_undef, result, ...) \ + do { \ + switch (sqlite3_##from##_type(__VA_ARGS__)) { \ + case SQLITE_INTEGER: { \ + sqlite3_int64 val = sqlite3_##from##_int64(__VA_ARGS__); \ + if ((use_big_int_args)) { \ + (result) = BigInt::New((isolate), val); \ + } else if (std::abs(val) <= kMaxSafeJsInteger) { \ + (result) = Number::New((isolate), val); \ + } else { \ + THROW_ERR_OUT_OF_RANGE((isolate), \ + "Value is too large to be represented as a " \ + "JavaScript number: %" PRId64, \ + val); \ + } \ + break; \ + } \ + case SQLITE_FLOAT: { \ + (result) = \ + Number::New((isolate), sqlite3_##from##_double(__VA_ARGS__)); \ + break; \ + } \ + case SQLITE_TEXT: { \ + const char* v = \ + reinterpret_cast(sqlite3_##from##_text(__VA_ARGS__)); \ + (result) = String::NewFromUtf8((isolate), v).As(); \ + break; \ + } \ + case SQLITE_NULL: { \ + (result) = (read_null_as_undef) ? Undefined((isolate)) : Null((isolate)); \ + break; \ + } \ + case SQLITE_BLOB: { \ + size_t size = \ + static_cast(sqlite3_##from##_bytes(__VA_ARGS__)); \ + auto data = reinterpret_cast( \ + sqlite3_##from##_blob(__VA_ARGS__)); \ + auto store = ArrayBuffer::NewBackingStore( \ + (isolate), size, BackingStoreInitializationMode::kUninitialized); \ + memcpy(store->Data(), data, size); \ + auto ab = ArrayBuffer::New((isolate), std::move(store)); \ + (result) = Uint8Array::New(ab, 0, size); \ + break; \ + } \ + default: \ + UNREACHABLE("Bad SQLite value"); \ + } \ + } while (0) + namespace { Local getLazyIterTemplate(Environment* env) { auto iter_template = env->iter_template(); @@ -2296,7 +2346,7 @@ bool StatementSync::BindValue(const Local& value, const int index) { MaybeLocal StatementSync::ColumnToValue(const int column) { return StatementExecutionHelper::ColumnToValue( - env(), statement_, column, use_big_ints_); + env(), statement_, column, use_big_ints_, read_null_as_undefined_); } MaybeLocal StatementSync::ColumnNameToName(const int column) { @@ -2312,10 +2362,12 @@ MaybeLocal StatementSync::ColumnNameToName(const int column) { MaybeLocal StatementExecutionHelper::ColumnToValue(Environment* env, sqlite3_stmt* stmt, const int column, - bool use_big_ints) { + bool use_big_ints, + bool read_null_as_undefined) { Isolate* isolate = env->isolate(); MaybeLocal js_val = MaybeLocal(); - SQLITE_VALUE_TO_JS(column, isolate, use_big_ints, js_val, stmt, column); + SQLITE_VALUE_TO_JS_READ( + column, isolate, use_big_ints, read_null_as_undefined, js_val, stmt, column); return js_val; } @@ -2337,12 +2389,13 @@ Maybe ExtractRowValues(Environment* env, sqlite3_stmt* stmt, int num_cols, bool use_big_ints, + bool read_null_as_undefined, LocalVector* row_values) { row_values->clear(); row_values->reserve(num_cols); for (int i = 0; i < num_cols; ++i) { Local val; - if (!StatementExecutionHelper::ColumnToValue(env, stmt, i, use_big_ints) + if (!StatementExecutionHelper::ColumnToValue(env, stmt, i, use_big_ints, read_null_as_undefined) .ToLocal(&val)) { return Nothing(); } @@ -2355,7 +2408,8 @@ MaybeLocal StatementExecutionHelper::All(Environment* env, DatabaseSync* db, sqlite3_stmt* stmt, bool return_arrays, - bool use_big_ints) { + bool use_big_ints, + bool read_null_as_undefined) { Isolate* isolate = env->isolate(); EscapableHandleScope scope(isolate); int r; @@ -2365,7 +2419,7 @@ MaybeLocal StatementExecutionHelper::All(Environment* env, LocalVector row_keys(isolate); while ((r = sqlite3_step(stmt)) == SQLITE_ROW) { - if (ExtractRowValues(env, stmt, num_cols, use_big_ints, &row_values) + if (ExtractRowValues(env, stmt, num_cols, use_big_ints, read_null_as_undefined, &row_values) .IsNothing()) { return MaybeLocal(); } @@ -2470,7 +2524,8 @@ MaybeLocal StatementExecutionHelper::Get(Environment* env, DatabaseSync* db, sqlite3_stmt* stmt, bool return_arrays, - bool use_big_ints) { + bool use_big_ints, + bool read_null_as_undefined) { Isolate* isolate = env->isolate(); EscapableHandleScope scope(isolate); auto reset = OnScopeLeave([&]() { sqlite3_reset(stmt); }); @@ -2488,7 +2543,7 @@ MaybeLocal StatementExecutionHelper::Get(Environment* env, } LocalVector row_values(isolate); - if (ExtractRowValues(env, stmt, num_cols, use_big_ints, &row_values) + if (ExtractRowValues(env, stmt, num_cols, use_big_ints, read_null_as_undefined, &row_values) .IsNothing()) { return MaybeLocal(); } @@ -2534,7 +2589,8 @@ void StatementSync::All(const FunctionCallbackInfo& args) { stmt->db_.get(), stmt->statement_, stmt->return_arrays_, - stmt->use_big_ints_) + stmt->use_big_ints_, + stmt->read_null_as_undefined_) .ToLocal(&result)) { args.GetReturnValue().Set(result); } @@ -2581,7 +2637,8 @@ void StatementSync::Get(const FunctionCallbackInfo& args) { stmt->db_.get(), stmt->statement_, stmt->return_arrays_, - stmt->use_big_ints_) + stmt->use_big_ints_, + stmt->read_null_as_undefined_) .ToLocal(&result)) { args.GetReturnValue().Set(result); } @@ -2738,6 +2795,22 @@ void StatementSync::SetReadBigInts(const FunctionCallbackInfo& args) { stmt->use_big_ints_ = args[0]->IsTrue(); } +void StatementSync::SetReadNullAsUndefined(const FunctionCallbackInfo& args) { + StatementSync* stmt; + ASSIGN_OR_RETURN_UNWRAP(&stmt, args.This()); + Environment* env = Environment::GetCurrent(args); + THROW_AND_RETURN_ON_BAD_STATE( + env, stmt->IsFinalized(), "statement has been finalized"); + + if (!args[0]->IsBoolean()) { + THROW_ERR_INVALID_ARG_TYPE( + env->isolate(), "The \"readNullAsUndefined\" argument must be a boolean."); + return; + } + + stmt->read_null_as_undefined_ = args[0]->IsTrue(); +} + void StatementSync::SetReturnArrays(const FunctionCallbackInfo& args) { StatementSync* stmt; ASSIGN_OR_RETURN_UNWRAP(&stmt, args.This()); @@ -2940,7 +3013,8 @@ void SQLTagStore::Get(const FunctionCallbackInfo& args) { stmt->db_.get(), stmt->statement_, stmt->return_arrays_, - stmt->use_big_ints_) + stmt->use_big_ints_, + stmt->read_null_as_undefined_) .ToLocal(&result)) { args.GetReturnValue().Set(result); } @@ -2980,7 +3054,8 @@ void SQLTagStore::All(const FunctionCallbackInfo& args) { stmt->db_.get(), stmt->statement_, stmt->return_arrays_, - stmt->use_big_ints_) + stmt->use_big_ints_, + stmt->read_null_as_undefined_) .ToLocal(&result)) { args.GetReturnValue().Set(result); } @@ -3114,6 +3189,8 @@ Local StatementSync::GetConstructorTemplate( isolate, tmpl, "setReadBigInts", StatementSync::SetReadBigInts); SetProtoMethod( isolate, tmpl, "setReturnArrays", StatementSync::SetReturnArrays); + SetProtoMethod( + isolate, tmpl, "setReadNullAsUndefined", StatementSync::SetReadNullAsUndefined); env->set_sqlite_statement_sync_constructor_template(tmpl); } return tmpl; @@ -3219,6 +3296,7 @@ void StatementSyncIterator::Next(const FunctionCallbackInfo& args) { iter->stmt_->statement_, num_cols, iter->stmt_->use_big_ints_, + iter->stmt_->read_null_as_undefined_, &row_values) .IsNothing()) { return; diff --git a/src/node_sqlite.h b/src/node_sqlite.h index 515660bf98999b..83642c058b72b6 100644 --- a/src/node_sqlite.h +++ b/src/node_sqlite.h @@ -65,6 +65,14 @@ class DatabaseOpenConfiguration { return allow_unknown_named_params_; } + inline void set_read_null_as_undefined(bool flag) { + read_null_as_undefined_ = flag; + } + + inline bool get_read_null_as_undefined() const { + return read_null_as_undefined_; + } + inline void set_enable_defensive(bool flag) { defensive_ = flag; } inline bool get_enable_defensive() const { return defensive_; } @@ -79,6 +87,7 @@ class DatabaseOpenConfiguration { bool return_arrays_ = false; bool allow_bare_named_params_ = true; bool allow_unknown_named_params_ = false; + bool read_null_as_undefined_ = false; bool defensive_ = true; }; @@ -93,7 +102,8 @@ class StatementExecutionHelper { DatabaseSync* db, sqlite3_stmt* stmt, bool return_arrays, - bool use_big_ints); + bool use_big_ints, + bool read_null_as_undefined); static v8::MaybeLocal Run(Environment* env, DatabaseSync* db, sqlite3_stmt* stmt, @@ -103,7 +113,8 @@ class StatementExecutionHelper { static v8::MaybeLocal ColumnToValue(Environment* env, sqlite3_stmt* stmt, const int column, - bool use_big_ints); + bool use_big_ints, + bool read_null_as_undefined); static v8::MaybeLocal ColumnNameToName(Environment* env, sqlite3_stmt* stmt, const int column); @@ -111,7 +122,8 @@ class StatementExecutionHelper { DatabaseSync* db, sqlite3_stmt* stmt, bool return_arrays, - bool use_big_ints); + bool use_big_ints, + bool read_null_as_undefined); }; class DatabaseSync : public BaseObject { @@ -168,6 +180,9 @@ class DatabaseSync : public BaseObject { bool allow_unknown_named_params() const { return open_config_.get_allow_unknown_named_params(); } + bool read_null_as_undefined() const { + return open_config_.get_read_null_as_undefined(); + } sqlite3* Connection(); // In some situations, such as when using custom functions, it is possible @@ -226,6 +241,8 @@ class StatementSync : public BaseObject { const v8::FunctionCallbackInfo& args); static void SetReadBigInts(const v8::FunctionCallbackInfo& args); static void SetReturnArrays(const v8::FunctionCallbackInfo& args); + static void SetReadNullAsUndefined( + const v8::FunctionCallbackInfo& args); v8::MaybeLocal ColumnToValue(const int column); v8::MaybeLocal ColumnNameToName(const int column); void Finalize(); @@ -242,6 +259,7 @@ class StatementSync : public BaseObject { bool use_big_ints_; bool allow_bare_named_params_; bool allow_unknown_named_params_; + bool read_null_as_undefined_; std::optional> bare_named_params_; bool BindParams(const v8::FunctionCallbackInfo& args); bool BindValue(const v8::Local& value, const int index); From 66ce7e451c580cdbadfae7907a89b1a66b699d1d Mon Sep 17 00:00:00 2001 From: Matt Skelley Date: Sun, 25 Jan 2026 14:47:33 +0800 Subject: [PATCH 2/2] sqlite: refactor existing macro. Refactor SQLITE_NULL case in SQLITE_VALUE_TO_JS. Remove SQLITE_VALUE_TO_JS_READ macro. Update sqlite docs. Add testing for SetReadNullAsUndefined implementation. --- doc/api/sqlite.md | 15 +++ src/node_sqlite.cc | 63 ++---------- test/parallel/test-sqlite-statement-sync.js | 105 ++++++++++++++++++++ 3 files changed, 128 insertions(+), 55 deletions(-) diff --git a/doc/api/sqlite.md b/doc/api/sqlite.md index bc834483c057a6..6d4250f886b062 100644 --- a/doc/api/sqlite.md +++ b/doc/api/sqlite.md @@ -997,6 +997,21 @@ 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.setReadNullAsUndefined(enabled)` + + + +* `enabled` {boolean} Enables or disables returning SQL `NULL` values as + JavaScript `undefined` when reading from the database. + +When reading from the database, SQLite `NULL` values are mapped to JavaScript +`null` by default. This method can be used to instead return `undefined` for +`NULL` values when materialising result rows. This setting only affects how +result rows are returned and does not impact values passed to user-defined +functions or aggregate functions. + ### `statement.sourceSQL`