Repository: nodejs/node
Pull request: sqlite: add support for custom functions

This commit adds support to node:sqlite for defining custom functions that can be invoked from SQL.

Fixes: https://github.com/nodejs/node/issues/54349

<!--
Before submitting a pull request, please read:

- the CONTRIBUTING guide at https://github.com/nodejs/node/blob/HEAD/CONTRIBUTING.md
- the commit message formatting guidelines at
  https://github.com/nodejs/node/blob/HEAD/doc/contributing/pull-requests.md#commit-message-guidelines

For code changes:
1. Include tests for any bug fixes or new features.
2. Update documentation if relevant.
3. Ensure that `make -j4 test` (UNIX), or `vcbuild test` (Windows) passes.

If you believe this PR should be highlighted in the Node.js CHANGELOG
please add the `notable-change` label.

Developer's Certificate of Origin 1.1

By making a contribution to this project, I certify that:

(a) The contribution was created in whole or in part by me and I
    have the right to submit it under the open source license
    indicated in the file; or

(b) The contribution is based upon previous work that, to the best
    of my knowledge, is covered under an appropriate open source
    license and I have the right under that license to submit that
    work with modifications, whether created in whole or in part
    by me, under the same open source license (unless I am
    permitted to submit under a different license), as indicated
    in the file; or

(c) The contribution was provided directly to me by some other
    person who certified (a), (b) or (c) and I have not modified
    it.

(d) I understand and agree that this project and the contribution
    are public and that a record of the contribution (including all
    personal information I submit with it, including my sign-off) is
    maintained indefinitely and may be redistributed consistent with
    this project or the open source license(s) involved.
-->


Changed files:
filename: doc/api/sqlite.md
patch: @@ -160,6 +160,31 @@ 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.function(name[, options], function)`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+* `name` {string} The name of the SQLite function to create.
+* `options` {Object} Optional configuration settings for the function. The
+  following properties are supported:
+  * `deterministic` {boolean} If `true`, the [`SQLITE_DETERMINISTIC`][] flag is
+    set on the created function. **Default:** `false`.
+  * `directOnly` {boolean} If `true`, the [`SQLITE_DIRECTONLY`][] flag is set on
+    the created function. **Default:** `false`.
+  * `useBigIntArguments` {boolean} If `true`, integer arguments to `function`
+    are converted to `BigInt`s. If `false`, integer arguments are passed as
+    JavaScript numbers. **Default:** `false`.
+  * `varargs` {boolean} If `true`, `function` can accept a variable number of
+    arguments. If `false`, `function` must be invoked with exactly
+    `function.length` arguments. **Default:** `false`.
+* `function` {Function} The JavaScript function to call when the SQLite
+  function is invoked.
+
+This method is used to create SQLite user-defined functions. This method is a
+wrapper around [`sqlite3_create_function_v2()`][].
+
 ### `database.open()`
 
 <!-- YAML
@@ -490,8 +515,11 @@ The following constants are meant for use with [`database.applyChangeset()`](#da
 [SQL injection]: https://en.wikipedia.org/wiki/SQL_injection
 [`ATTACH DATABASE`]: https://www.sqlite.org/lang_attach.html
 [`PRAGMA foreign_keys`]: https://www.sqlite.org/pragma.html#pragma_foreign_keys
+[`SQLITE_DETERMINISTIC`]: https://www.sqlite.org/c3ref/c_deterministic.html
+[`SQLITE_DIRECTONLY`]: https://www.sqlite.org/c3ref/c_deterministic.html
 [`sqlite3_changes64()`]: https://www.sqlite.org/c3ref/changes.html
 [`sqlite3_close_v2()`]: https://www.sqlite.org/c3ref/close.html
+[`sqlite3_create_function_v2()`]: https://www.sqlite.org/c3ref/create_function.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

filename: src/node_sqlite.cc
patch: @@ -28,6 +28,8 @@ using v8::Function;
 using v8::FunctionCallback;
 using v8::FunctionCallbackInfo;
 using v8::FunctionTemplate;
+using v8::Global;
+using v8::Int32;
 using v8::Integer;
 using v8::Isolate;
 using v8::Local;
@@ -112,6 +114,123 @@ inline void THROW_ERR_SQLITE_ERROR(Isolate* isolate, const char* message) {
   }
 }
 
+class UserDefinedFunction {
+ public:
+  explicit UserDefinedFunction(Environment* env,
+                               Local<Function> fn,
+                               bool use_bigint_args)
+      : env_(env), fn_(env->isolate(), fn), use_bigint_args_(use_bigint_args) {}
+  virtual ~UserDefinedFunction() {}
+
+  static void xFunc(sqlite3_context* ctx, int argc, sqlite3_value** argv) {
+    UserDefinedFunction* self =
+        static_cast<UserDefinedFunction*>(sqlite3_user_data(ctx));
+    Environment* env = self->env_;
+    Isolate* isolate = env->isolate();
+    auto recv = Undefined(isolate);
+    auto fn = self->fn_.Get(isolate);
+    LocalVector<Value> js_argv(isolate);
+
+    for (int i = 0; i < argc; ++i) {
+      sqlite3_value* value = argv[i];
+      MaybeLocal<Value> js_val;
+
+      switch (sqlite3_value_type(value)) {
+        case SQLITE_INTEGER: {
+          sqlite3_int64 val = sqlite3_value_int64(value);
+          if (self->use_bigint_args_) {
+            js_val = BigInt::New(isolate, val);
+          } else if (std::abs(val) <= kMaxSafeJsInteger) {
+            js_val = Number::New(isolate, val);
+          } else {
+            THROW_ERR_OUT_OF_RANGE(isolate,
+                                   "Value is too large to be represented as a "
+                                   "JavaScript number: %" PRId64,
+                                   val);
+            return;
+          }
+          break;
+        }
+        case SQLITE_FLOAT:
+          js_val = Number::New(isolate, sqlite3_value_double(value));
+          break;
+        case SQLITE_TEXT: {
+          const char* v =
+              reinterpret_cast<const char*>(sqlite3_value_text(value));
+          js_val = String::NewFromUtf8(isolate, v).As<Value>();
+          break;
+        }
+        case SQLITE_NULL:
+          js_val = Null(isolate);
+          break;
+        case SQLITE_BLOB: {
+          size_t size = static_cast<size_t>(sqlite3_value_bytes(value));
+          auto data =
+              reinterpret_cast<const uint8_t*>(sqlite3_value_blob(value));
+          auto store = ArrayBuffer::NewBackingStore(isolate, size);
+          memcpy(store->Data(), data, size);
+          auto ab = ArrayBuffer::New(isolate, std::move(store));
+          js_val = Uint8Array::New(ab, 0, size);
+          break;
+        }
+        default:
+          UNREACHABLE("Bad SQLite value");
+      }
+
+      Local<Value> local;
+      if (!js_val.ToLocal(&local)) {
+        return;
+      }
+
+      js_argv.emplace_back(local);
+    }
+
+    MaybeLocal<Value> retval =
+        fn->Call(env->context(), recv, argc, js_argv.data());
+    Local<Value> result;
+    if (!retval.ToLocal(&result)) {
+      return;
+    }
+
+    if (result->IsUndefined() || result->IsNull()) {
+      sqlite3_result_null(ctx);
+    } else if (result->IsNumber()) {
+      sqlite3_result_double(ctx, result.As<Number>()->Value());
+    } else if (result->IsString()) {
+      Utf8Value val(isolate, result.As<String>());
+      sqlite3_result_text(ctx, *val, val.length(), SQLITE_TRANSIENT);
+    } else if (result->IsUint8Array()) {
+      ArrayBufferViewContents<uint8_t> buf(result);
+      sqlite3_result_blob(ctx, buf.data(), buf.length(), SQLITE_TRANSIENT);
+    } else if (result->IsBigInt()) {
+      bool lossless;
+      int64_t as_int = result.As<BigInt>()->Int64Value(&lossless);
+      if (!lossless) {
+        sqlite3_result_error(ctx, "BigInt value is too large for SQLite", -1);
+        return;
+      }
+      sqlite3_result_int64(ctx, as_int);
+    } else if (result->IsPromise()) {
+      sqlite3_result_error(
+          ctx, "Asynchronous user-defined functions are not supported", -1);
+    } else {
+      sqlite3_result_error(
+          ctx,
+          "Returned JavaScript value cannot be converted to a SQLite value",
+          -1);
+    }
+  }
+
+  static void xDestroy(void* self) {
+    delete static_cast<UserDefinedFunction*>(self);
+  }
+
+ private:
+  Environment* env_;
+  Global<Function> fn_;
+  bool use_bigint_args_;
+};
+
 DatabaseSync::DatabaseSync(Environment* env,
                            Local<Object> object,
                            DatabaseOpenConfiguration&& open_config,
@@ -400,6 +519,151 @@ void DatabaseSync::Exec(const FunctionCallbackInfo<Value>& args) {
   CHECK_ERROR_OR_THROW(env->isolate(), db->connection_, r, SQLITE_OK, void());
 }
 
+void DatabaseSync::CustomFunction(const FunctionCallbackInfo<Value>& args) {
+  DatabaseSync* db;
+  ASSIGN_OR_RETURN_UNWRAP(&db, args.This());
+  Environment* env = Environment::GetCurrent(args);
+  THROW_AND_RETURN_ON_BAD_STATE(env, !db->IsOpen(), "database is not open");
+
+  if (!args[0]->IsString()) {
+    THROW_ERR_INVALID_ARG_TYPE(env->isolate(),
+                               "The \"name\" argument must be a string.");
+    return;
+  }
+
+  int fn_index = args.Length() < 3 ? 1 : 2;
+  bool use_bigint_args = false;
+  bool varargs = false;
+  bool deterministic = false;
+  bool direct_only = false;
+
+  if (fn_index > 1) {
+    if (!args[1]->IsObject()) {
+      THROW_ERR_INVALID_ARG_TYPE(env->isolate(),
+                                 "The \"options\" argument must be an object.");
+      return;
+    }
+
+    Local<Object> options = args[1].As<Object>();
+    Local<Value> use_bigint_args_v;
+    if (!options
+             ->Get(env->context(),
+                   FIXED_ONE_BYTE_STRING(env->isolate(), "useBigIntArguments"))
+             .ToLocal(&use_bigint_args_v)) {
+      return;
+    }
+
+    if (!use_bigint_args_v->IsUndefined()) {
+      if (!use_bigint_args_v->IsBoolean()) {
+        THROW_ERR_INVALID_ARG_TYPE(
+            env->isolate(),
+            "The \"options.useBigIntArguments\" argument must be a boolean.");
+        return;
+      }
+      use_bigint_args = use_bigint_args_v.As<Boolean>()->Value();
+    }
+
+    Local<Value> varargs_v;
+    if (!options
+             ->Get(env->context(),
+                   FIXED_ONE_BYTE_STRING(env->isolate(), "varargs"))
+             .ToLocal(&varargs_v)) {
+      return;
+    }
+
+    if (!varargs_v->IsUndefined()) {
+      if (!varargs_v->IsBoolean()) {
+        THROW_ERR_INVALID_ARG_TYPE(
+            env->isolate(),
+            "The \"options.varargs\" argument must be a boolean.");
+        return;
+      }
+      varargs = varargs_v.As<Boolean>()->Value();
+    }
+
+    Local<Value> deterministic_v;
+    if (!options
+             ->Get(env->context(),
+                   FIXED_ONE_BYTE_STRING(env->isolate(), "deterministic"))
+             .ToLocal(&deterministic_v)) {
+      return;
+    }
+
+    if (!deterministic_v->IsUndefined()) {
+      if (!deterministic_v->IsBoolean()) {
+        THROW_ERR_INVALID_ARG_TYPE(
+            env->isolate(),
+            "The \"options.deterministic\" argument must be a boolean.");
+        return;
+      }
+      deterministic = deterministic_v.As<Boolean>()->Value();
+    }
+
+    Local<Value> direct_only_v;
+    if (!options
+             ->Get(env->context(),
+                   FIXED_ONE_BYTE_STRING(env->isolate(), "directOnly"))
+             .ToLocal(&direct_only_v)) {
+      return;
+    }
+
+    if (!direct_only_v->IsUndefined()) {
+      if (!direct_only_v->IsBoolean()) {
+        THROW_ERR_INVALID_ARG_TYPE(
+            env->isolate(),
+            "The \"options.directOnly\" argument must be a boolean.");
+        return;
+      }
+      direct_only = direct_only_v.As<Boolean>()->Value();
+    }
+  }
+
+  if (!args[fn_index]->IsFunction()) {
+    THROW_ERR_INVALID_ARG_TYPE(env->isolate(),
+                               "The \"function\" argument must be a function.");
+    return;
+  }
+
+  Utf8Value name(env->isolate(), args[0].As<String>());
+  Local<Function> fn = args[fn_index].As<Function>();
+
+  int argc = 0;
+  if (varargs) {
+    argc = -1;
+  } else {
+    Local<Value> js_len;
+    if (!fn->Get(env->context(),
+                 FIXED_ONE_BYTE_STRING(env->isolate(), "length"))
+             .ToLocal(&js_len)) {
+      return;
+    }
+    argc = js_len.As<Int32>()->Value();
+  }
+
+  UserDefinedFunction* user_data =
+      new UserDefinedFunction(env, fn, use_bigint_args);
+  int text_rep = SQLITE_UTF8;
+
+  if (deterministic) {
+    text_rep |= SQLITE_DETERMINISTIC;
+  }
+
+  if (direct_only) {
+    text_rep |= SQLITE_DIRECTONLY;
+  }
+
+  int r = sqlite3_create_function_v2(db->connection_,
+                                     *name,
+                                     argc,
+                                     text_rep,
+                                     user_data,
+                                     UserDefinedFunction::xFunc,
+                                     nullptr,
+                                     nullptr,
+                                     UserDefinedFunction::xDestroy);
+  CHECK_ERROR_OR_THROW(env->isolate(), db->connection_, r, SQLITE_OK, void());
+}
+
 void DatabaseSync::CreateSession(const FunctionCallbackInfo<Value>& args) {
   std::string table;
   std::string db_name = "main";
@@ -1409,6 +1673,7 @@ static void Initialize(Local<Object> target,
   SetProtoMethod(isolate, db_tmpl, "close", DatabaseSync::Close);
   SetProtoMethod(isolate, db_tmpl, "prepare", DatabaseSync::Prepare);
   SetProtoMethod(isolate, db_tmpl, "exec", DatabaseSync::Exec);
+  SetProtoMethod(isolate, db_tmpl, "function", DatabaseSync::CustomFunction);
   SetProtoMethod(
       isolate, db_tmpl, "createSession", DatabaseSync::CreateSession);
   SetProtoMethod(

filename: src/node_sqlite.h
patch: @@ -57,6 +57,7 @@ class DatabaseSync : public BaseObject {
   static void Close(const v8::FunctionCallbackInfo<v8::Value>& args);
   static void Prepare(const v8::FunctionCallbackInfo<v8::Value>& args);
   static void Exec(const v8::FunctionCallbackInfo<v8::Value>& args);
+  static void CustomFunction(const v8::FunctionCallbackInfo<v8::Value>& args);
   static void CreateSession(const v8::FunctionCallbackInfo<v8::Value>& args);
   static void ApplyChangeset(const v8::FunctionCallbackInfo<v8::Value>& args);
   static void EnableLoadExtension(

filename: test/parallel/test-sqlite-custom-functions.js
patch: @@ -0,0 +1,373 @@
+'use strict';
+require('../common');
+const assert = require('node:assert');
+const { DatabaseSync } = require('node:sqlite');
+const { suite, test } = require('node:test');
+
+suite('DatabaseSync.prototype.function()', () => {
+  suite('input validation', () => {
+    const db = new DatabaseSync(':memory:');
+
+    test('throws if name is not a string', () => {
+      assert.throws(() => {
+        db.function();
+      }, {
+        code: 'ERR_INVALID_ARG_TYPE',
+        message: /The "name" argument must be a string/,
+      });
+    });
+
+    test('throws if function is not a function', () => {
+      assert.throws(() => {
+        db.function('foo');
+      }, {
+        code: 'ERR_INVALID_ARG_TYPE',
+        message: /The "function" argument must be a function/,
+      });
+    });
+
+    test('throws if options is not an object', () => {
+      assert.throws(() => {
+        db.function('foo', null, () => {});
+      }, {
+        code: 'ERR_INVALID_ARG_TYPE',
+        message: /The "options" argument must be an object/,
+      });
+    });
+
+    test('throws if options.useBigIntArguments is not a boolean', () => {
+      assert.throws(() => {
+        db.function('foo', { useBigIntArguments: null }, () => {});
+      }, {
+        code: 'ERR_INVALID_ARG_TYPE',
+        message: /The "options\.useBigIntArguments" argument must be a boolean/,
+      });
+    });
+
+    test('throws if options.varargs is not a boolean', () => {
+      assert.throws(() => {
+        db.function('foo', { varargs: null }, () => {});
+      }, {
+        code: 'ERR_INVALID_ARG_TYPE',
+        message: /The "options\.varargs" argument must be a boolean/,
+      });
+    });
+
+    test('throws if options.deterministic is not a boolean', () => {
+      assert.throws(() => {
+        db.function('foo', { deterministic: null }, () => {});
+      }, {
+        code: 'ERR_INVALID_ARG_TYPE',
+        message: /The "options\.deterministic" argument must be a boolean/,
+      });
+    });
+
+    test('throws if options.directOnly is not a boolean', () => {
+      assert.throws(() => {
+        db.function('foo', { directOnly: null }, () => {});
+      }, {
+        code: 'ERR_INVALID_ARG_TYPE',
+        message: /The "options\.directOnly" argument must be a boolean/,
+      });
+    });
+  });
+
+  suite('useBigIntArguments', () => {
+    test('converts arguments to BigInts when true', () => {
+      const db = new DatabaseSync(':memory:');
+      let value;
+      const r = db.function('custom', { useBigIntArguments: true }, (arg) => {
+        value = arg;
+      });
+      assert.strictEqual(r, undefined);
+      db.prepare('SELECT custom(5) AS custom').get();
+      assert.strictEqual(value, 5n);
+    });
+
+    test('uses number primitives when false', () => {
+      const db = new DatabaseSync(':memory:');
+      let value;
+      const r = db.function('custom', { useBigIntArguments: false }, (arg) => {
+        value = arg;
+      });
+      assert.strictEqual(r, undefined);
+      db.prepare('SELECT custom(5) AS custom').get();
+      assert.strictEqual(value, 5);
+    });
+
+    test('defaults to false', () => {
+      const db = new DatabaseSync(':memory:');
+      let value;
+      const r = db.function('custom', (arg) => {
+        value = arg;
+      });
+      assert.strictEqual(r, undefined);
+      db.prepare('SELECT custom(5) AS custom').get();
+      assert.strictEqual(value, 5);
+    });
+
+    test('throws if value cannot fit in a number', () => {
+      const db = new DatabaseSync(':memory:');
+      const value = Number.MAX_SAFE_INTEGER + 1;
+      db.function('custom', (arg) => {});
+      assert.throws(() => {
+        db.prepare(`SELECT custom(${value}) AS custom`).get();
+      }, {
+        code: 'ERR_OUT_OF_RANGE',
+        message: /Value is too large to be represented as a JavaScript number: 9007199254740992/,
+      });
+    });
+  });
+
+  suite('varargs', () => {
+    test('supports variable number of arguments when true', () => {
+      const db = new DatabaseSync(':memory:');
+      let value;
+      const r = db.function('custom', { varargs: true }, (...args) => {
+        value = args;
+      });
+      assert.strictEqual(r, undefined);
+      db.prepare('SELECT custom(5, 4, 3, 2, 1) AS custom').get();
+      assert.deepStrictEqual(value, [5, 4, 3, 2, 1]);
+    });
+
+    test('uses function.length when false', () => {
+      const db = new DatabaseSync(':memory:');
+      let value;
+      const r = db.function('custom', { varargs: false }, (a, b, c) => {
+        value = [a, b, c];
+      });
+      assert.strictEqual(r, undefined);
+      db.prepare('SELECT custom(1, 2, 3) AS custom').get();
+      assert.deepStrictEqual(value, [1, 2, 3]);
+    });
+
+    test('defaults to false', () => {
+      const db = new DatabaseSync(':memory:');
+      let value;
+      const r = db.function('custom', (a, b, c) => {
+        value = [a, b, c];
+      });
+      assert.strictEqual(r, undefined);
+      db.prepare('SELECT custom(7, 8, 9) AS custom').get();
+      assert.deepStrictEqual(value, [7, 8, 9]);
+    });
+
+    test('throws if an incorrect number of arguments is provided', () => {
+      const db = new DatabaseSync(':memory:');
+      db.function('custom', (a, b, c, d) => {});
+      assert.throws(() => {
+        db.prepare('SELECT custom(1, 2, 3) AS custom').get();
+      }, {
+        code: 'ERR_SQLITE_ERROR',
+        message: /wrong number of arguments to function custom\(\)/,
+      });
+    });
+  });
+
+  suite('deterministic', () => {
+    test('creates a deterministic function when true', () => {
+      const db = new DatabaseSync(':memory:');
+      db.function('isDeterministic', { deterministic: true }, () => {
+        return 42;
+      });
+      const r = db.exec(`
+        CREATE TABLE t1 (
+          a INTEGER PRIMARY KEY,
+          b INTEGER GENERATED ALWAYS AS (isDeterministic()) VIRTUAL
+        )
+      `);
+      assert.strictEqual(r, undefined);
+    });
+
+    test('creates a non-deterministic function when false', () => {
+      const db = new DatabaseSync(':memory:');
+      db.function('isNonDeterministic', { deterministic: false }, () => {
+        return 42;
+      });
+      assert.throws(() => {
+        db.exec(`
+          CREATE TABLE t1 (
+            a INTEGER PRIMARY KEY,
+            b INTEGER GENERATED ALWAYS AS (isNonDeterministic()) VIRTUAL
+          )
+        `);
+      }, {
+        code: 'ERR_SQLITE_ERROR',
+        message: /non-deterministic functions prohibited in generated columns/,
+      });
+    });
+
+    test('deterministic defaults to false', () => {
+      const db = new DatabaseSync(':memory:');
+      db.function('isNonDeterministic', () => {
+        return 42;
+      });
+      assert.throws(() => {
+        db.exec(`
+          CREATE TABLE t1 (
+            a INTEGER PRIMARY KEY,
+            b INTEGER GENERATED ALWAYS AS (isNonDeterministic()) VIRTUAL
+          )
+        `);
+      }, {
+        code: 'ERR_SQLITE_ERROR',
+        message: /non-deterministic functions prohibited in generated columns/,
+      });
+    });
+  });
+
+  suite('directOnly', () => {
+    test('sets SQLite direct only flag when true', () => {
+      const db = new DatabaseSync(':memory:');
+      db.function('fn', { deterministic: true, directOnly: true }, () => {
+        return 42;
+      });
+      assert.throws(() => {
+        db.exec(`
+          CREATE TABLE t1 (
+            a INTEGER PRIMARY KEY,
+            b INTEGER GENERATED ALWAYS AS (fn()) VIRTUAL
+          )
+        `);
+      }, {
+        code: 'ERR_SQLITE_ERROR',
+        message: /unsafe use of fn\(\)/
+      });
+    });
+
+    test('does not set SQLite direct only flag when false', () => {
+      const db = new DatabaseSync(':memory:');
+      db.function('fn', { deterministic: true, directOnly: false }, () => {
+        return 42;
+      });
+      const r = db.exec(`
+        CREATE TABLE t1 (
+          a INTEGER PRIMARY KEY,
+          b INTEGER GENERATED ALWAYS AS (fn()) VIRTUAL
+        )
+      `);
+      assert.strictEqual(r, undefined);
+    });
+
+    test('directOnly defaults to false', () => {
+      const db = new DatabaseSync(':memory:');
+      db.function('fn', { deterministic: true }, () => {
+        return 42;
+      });
+      const r = db.exec(`
+        CREATE TABLE t1 (
+          a INTEGER PRIMARY KEY,
+          b INTEGER GENERATED ALWAYS AS (fn()) VIRTUAL
+        )
+      `);
+      assert.strictEqual(r, undefined);
+    });
+  });
+
+  suite('return types', () => {
+    test('supported return types', () => {
+      const db = new DatabaseSync(':memory:');
+      db.function('retUndefined', () => {});
+      db.function('retNull', () => { return null; });
+      db.function('retNumber', () => { return 3; });
+      db.function('retString', () => { return 'foo'; });
+      db.function('retBigInt', () => { return 5n; });
+      db.function('retUint8Array', () => { return new Uint8Array([1, 2, 3]); });
+      const stmt = db.prepare(`SELECT
+        retUndefined() AS retUndefined,
+        retNull() AS retNull,
+        retNumber() AS retNumber,
+        retString() AS retString,
+        retBigInt() AS retBigInt,
+        retUint8Array() AS retUint8Array
+      `);
+      assert.deepStrictEqual(stmt.get(), {
+        __proto__: null,
+        retUndefined: null,
+        retNull: null,
+        retNumber: 3,
+        retString: 'foo',
+        retBigInt: 5,
+        retUint8Array: new Uint8Array([1, 2, 3]),
+      });
+    });
+
+    test('throws if returned BigInt is too large for SQLite', () => {
+      const db = new DatabaseSync(':memory:');
+      db.function('retBigInt', () => {
+        return BigInt(Number.MAX_SAFE_INTEGER + 1);
+      });
+      const stmt = db.prepare('SELECT retBigInt() AS retBigInt');
+      assert.throws(() => {
+        stmt.get();
+      }, {
+        code: 'ERR_OUT_OF_RANGE',
+      });
+    });
+
+    test('does not support Promise return values', () => {
+      const db = new DatabaseSync(':memory:');
+      db.function('retPromise', async () => {});
+      const stmt = db.prepare('SELECT retPromise() AS retPromise');
+      assert.throws(() => {
+        stmt.get();
+      }, {
+        code: 'ERR_SQLITE_ERROR',
+        message: /Asynchronous user-defined functions are not supported/,
+      });
+    });
+
+    test('throws on unsupported return types', () => {
+      const db = new DatabaseSync(':memory:');
+      db.function('retFunction', () => {
+        return () => {};
+      });
+      const stmt = db.prepare('SELECT retFunction() AS retFunction');
+      assert.throws(() => {
+        stmt.get();
+      }, {
+        code: 'ERR_SQLITE_ERROR',
+        message: /Returned JavaScript value cannot be converted to a SQLite value/,
+      });
+    });
+  });
+
+  test('supported argument types', () => {
+    const db = new DatabaseSync(':memory:');
+    db.function('arguments', (i, f, s, n, b) => {
+      assert.strictEqual(i, 5);
+      assert.strictEqual(f, 3.14);
+      assert.strictEqual(s, 'foo');
+      assert.strictEqual(n, null);
+      assert.deepStrictEqual(b, new Uint8Array([254]));
+      return 42;
+    });
+    const stmt = db.prepare(
+      'SELECT arguments(5, 3.14, \'foo\', null, x\'fe\') as result'
+    );
+    assert.deepStrictEqual(stmt.get(), { __proto__: null, result: 42 });
+  });
+
+  test('propagates thrown errors', () => {
+    const db = new DatabaseSync(':memory:');
+    const err = new Error('boom');
+    db.function('throws', () => {
+      throw err;
+    });
+    const stmt = db.prepare('SELECT throws()');
+    assert.throws(() => {
+      stmt.get();
+    }, err);
+  });
+
+  test('throws if database is not open', () => {
+    const db = new DatabaseSync(':memory:', { open: false });
+    assert.throws(() => {
+      db.function('foo', () => {});
+    }, {
+      code: 'ERR_INVALID_STATE',
+      message: /database is not open/,
+    });
+  });
+});