diff --git a/.gitignore b/.gitignore index 230acc784e2..4391680c3df 100644 --- a/.gitignore +++ b/.gitignore @@ -22,6 +22,12 @@ scripts/antlr4/antlr4.jar # macOS .DS_Store +### Node.js +tools/nodejs_api/node_modules/ +tools/nodejs_api/cmake_install.cmake +tools/nodejs_api/package-lock.json +tools/nodejs_api/testDb/ + # Archive files *.zip *.tar.gz diff --git a/tools/nodejs_api/CMakeLists.txt b/tools/nodejs_api/CMakeLists.txt new file mode 100644 index 00000000000..83f7f9a402c --- /dev/null +++ b/tools/nodejs_api/CMakeLists.txt @@ -0,0 +1,42 @@ +cmake_minimum_required(VERSION 3.25) +project (kuzujs) + +set(CMAKE_CXX_STANDARD 20) + +add_definitions(-DNAPI_VERSION=5) + +if (CMAKE_BUILD_TYPE EQUAL "DEBUG") + find_library(KUZU NAMES kuzu PATHS ../../build/debug/src) + set(THIRD_PARTY_BIN_PATH ../../build/debug/third_party) +else() + find_library(KUZU NAMES kuzu PATHS ../../build/release/src) + set(THIRD_PARTY_BIN_PATH ../../build/release/third_party) +endif() + +get_filename_component(NODE_ADDON_API_INCLUDE_PATH ./node_modules/node-addon-api ABSOLUTE) +get_filename_component(KUZU_INCLUDE_PATH ../../src/include ABSOLUTE) +get_filename_component(ANTLR4_CYPHER_INCLUDE_PATH ../../third_party/antlr4_cypher/include ABSOLUTE) +get_filename_component(ANTLR4_RUNTIME_INCLUDE_PATH ../../third_party/antlr4_runtime/src ABSOLUTE) +get_filename_component(SPDLOG_INCLUDE_PATH ../../third_party/spdlog ABSOLUTE) +get_filename_component(NLOHMANN_JSON_INCLUDE_PATH ../../third_party/nlohmann_json ABSOLUTE) +get_filename_component(UTF8PROC_INCLUDE_PATH ../../third_party/utf8proc/include ABSOLUTE) +get_filename_component(CONCURRENT_QUEUE_INCLUDE_PATH ../../third_party/concurrentqueue ABSOLUTE) +get_filename_component(THIRD_PARTY_PATH ../../third_party ABSOLUTE) + +include_directories(${CMAKE_JS_INC}) +include_directories(${NODE_ADDON_API_INCLUDE_PATH}) +include_directories(${KUZU_INCLUDE_PATH}) +include_directories(${ANTLR4_CYPHER_INCLUDE_PATH}) +include_directories(${ANTLR4_RUNTIME_INCLUDE_PATH}) +include_directories(${SPDLOG_INCLUDE_PATH}) +include_directories(${NLOHMANN_JSON_INCLUDE_PATH}) +include_directories(${UTF8PROC_INCLUDE_PATH}) +include_directories(${CONCURRENT_QUEUE_INCLUDE_PATH}) + +file(GLOB SOURCE_FILES ./src_cpp/*) +file(GLOB CMAKE_JS_SRC ./src_nodejs/*) +file(COPY ${CMAKE_JS_SRC} DESTINATION ./kuzu) + +add_library(${PROJECT_NAME} SHARED ${SOURCE_FILES} ${CMAKE_JS_SRC}) +set_target_properties(${PROJECT_NAME} PROPERTIES PREFIX "" SUFFIX ".node") +target_link_libraries(${PROJECT_NAME} PRIVATE ${CMAKE_JS_LIB} ${KUZU}) diff --git a/tools/nodejs_api/README.md b/tools/nodejs_api/README.md new file mode 100644 index 00000000000..5009a27ddad --- /dev/null +++ b/tools/nodejs_api/README.md @@ -0,0 +1,25 @@ +## Install dependency +``` +npm i +``` + +## Build +``` +npm run build +``` + +## Clean +``` +npm run clean +``` + +## Run test +``` +npm run test +``` + +## Run sample +``` +node sample.js +``` + diff --git a/tools/nodejs_api/package.json b/tools/nodejs_api/package.json new file mode 100644 index 00000000000..3cf106d8c2a --- /dev/null +++ b/tools/nodejs_api/package.json @@ -0,0 +1,28 @@ +{ + "name": "kuzu", + "version": "0.0.1", + "description": "Node.js API for Kùzu graph database management system", + "main": "index.js", + "homepage": "https://kuzudb.com/", + "repository": { + "type": "git", + "url": "https://github.com/kuzudb/kuzu.git" + }, + "scripts": { + "build": "(cd ../..; make release) && cmake-js compile && npm run js", + "clean": "cmake-js clean && rm -rf cmake_install.cmake Makefile", + "js": "cp src_nodejs/* build/kuzu/", + "all": "npm run clean && npm run build && node sample.js", + "test": "mocha", + "buildtest": "npm run build && mocha" + }, + "author": "Kùzu Team", + "license": "MIT", + "dependencies": { + "chai": "^4.3.7", + "cmake-js": "^7.1.1", + "mocha": "^10.2.0", + "node-addon-api": "^6.0.0", + "tmp": "^0.2.1" + } +} diff --git a/tools/nodejs_api/sample.js b/tools/nodejs_api/sample.js new file mode 100644 index 00000000000..c2d83cb9b91 --- /dev/null +++ b/tools/nodejs_api/sample.js @@ -0,0 +1,120 @@ +// Make sure the test directory is removed as it will be recreated +const fs = require("fs"); +const {Database, Connection} = require("./build/kuzu"); + +try { + fs.rmSync("./testDb", { recursive: true }); +} catch (e) { + // ignore +} + +async function executeAllCallback(err, queryResult) { + if (err) { + console.log(err); + } else { + await queryResult.all({"callback": (err, result) => { + if (err) { + console.log("All result with Callback failed"); + console.log(err); + } else { + console.log(result); + console.log("All result received Callback"); + } + }}); + } +} + +async function executeAllPromise(err, queryResult) { + if (err) { + console.log(err); + } else { + await queryResult.all().then(result => { + console.log(result); + console.log("All result received Promise"); + }).catch(error => { + console.log("All with Promise failed"); + console.log(error); + }); + } +} + +// Basic Case with all callback +const database = new Database("testDb", 1000000000); +console.log("The database looks like: ", database); +const connection = new Connection(database); +console.log ("The connection looks like: ", connection); +connection.execute("create node table person (ID INt64, fName StRING, gender INT64, isStudent BoOLEAN, isWorker BOOLEAN, age INT64, eyeSight DOUBLE, birthdate DATE, registerTime TIMESTAMP, lastJobDuration interval, workedHours INT64[], usedNames STRING[], courseScoresPerTerm INT64[][], grades INT64[], height DOUBLE, PRIMARY KEY (ID));", {"callback": executeAllCallback}).then(async r => { + await connection.execute("COPY person FROM \"../../dataset/tinysnb/vPerson.csv\" (HEADER=true);", {"callback": executeAllPromise}) + const executeQuery = "MATCH (a:person) RETURN a.fName, a.age, a.eyeSight, a.isStudent;"; + const parameterizedExecuteQuery = "MATCH (a:person) WHERE a.age > $1 and a.isStudent = $2 and a.fName < $3 RETURN a.fName, a.age, a.eyeSight, a.isStudent;"; + + connection.execute(executeQuery, {"callback": executeAllPromise}); + connection.execute(parameterizedExecuteQuery, { + "callback": executeAllPromise, + "params": [["1", 29], ["2", true], ["3", "B"]] + }); + + // Extensive Case + connection.setMaxNumThreadForExec(2); + connection.execute(executeQuery, {"callback": executeAllCallback}); + + // Execute with each callback + connection.execute(executeQuery, { + "callback": async (err, result) => { + if (err) { + console.log(err); + } else { + await result.each( + (err, rowResult) => { + if (err) { + console.log(err) + } else { + console.log(rowResult); + } + }, + () => { + console.log("all of the each's are done callback"); + } + ); + } + } + }); + + // Execute with promise + await + connection.execute(executeQuery).then(async queryResult => { + await queryResult.all({ + "callback": (err, result) => { + if (err) { + console.log(err); + } else { + console.log(result); + console.log("All result received for execution with a promise"); + } + } + }); + }).catch(error => { + console.log("Execution with a promise failed"); + console.log(error); + }); + + async function asyncAwaitExecute(executeQuery) { + const queryResult = await connection.execute(executeQuery); + return queryResult; + } + + await asyncAwaitExecute(executeQuery).then(async queryResult => { + await queryResult.all({ + "callback": (err, result) => { + if (err) { + console.log(err); + } else { + console.log(result); + console.log("All result received for execution with await"); + } + } + }); + }).catch(error => { + console.log("Execution with await failed"); + console.log(error); + }); +}); \ No newline at end of file diff --git a/tools/nodejs_api/src_cpp/execute_async_worker.cpp b/tools/nodejs_api/src_cpp/execute_async_worker.cpp new file mode 100644 index 00000000000..48e4f1e850f --- /dev/null +++ b/tools/nodejs_api/src_cpp/execute_async_worker.cpp @@ -0,0 +1,37 @@ +#include "include/execute_async_worker.h" + +#include +#include + +#include "include/node_query_result.h" + +using namespace Napi; + +ExecuteAsyncWorker::ExecuteAsyncWorker(Function& callback, shared_ptr& connection, + std::string query, NodeQueryResult * nodeQueryResult, unordered_map>& params) + : AsyncWorker(callback), connection(connection), query(query), nodeQueryResult(nodeQueryResult) + , params(params) {}; + +void ExecuteAsyncWorker::Execute() { + try { + shared_ptr queryResult; + if (!params.empty()) { + auto preparedStatement = std::move(connection->prepare(query)); + queryResult = connection->executeWithParams(preparedStatement.get(), params); + } else { + queryResult = connection->query(query); + } + + if (!queryResult->isSuccess()) { + SetError("Query async execute unsuccessful: " + queryResult->getErrorMessage()); + } + nodeQueryResult->SetQueryResult(queryResult); + } + catch(const std::exception &exc) { + SetError("Unsuccessful async execute: " + std::string(exc.what())); + } +}; + +void ExecuteAsyncWorker::OnOK() { + Callback().Call({Env().Null()}); +}; diff --git a/tools/nodejs_api/src_cpp/include/execute_async_worker.h b/tools/nodejs_api/src_cpp/include/execute_async_worker.h new file mode 100644 index 00000000000..c5485632305 --- /dev/null +++ b/tools/nodejs_api/src_cpp/include/execute_async_worker.h @@ -0,0 +1,23 @@ +#pragma once +#include +#include "main/kuzu.h" +#include "node_query_result.h" + +using namespace std; + +class ExecuteAsyncWorker : public Napi::AsyncWorker { + +public: + ExecuteAsyncWorker(Napi::Function& callback, shared_ptr& connection, + std::string query, NodeQueryResult * nodeQueryResult, unordered_map> & params); + virtual ~ExecuteAsyncWorker() {}; + + void Execute(); + void OnOK(); + +private: + NodeQueryResult * nodeQueryResult; + std::string query; + shared_ptr connection; + unordered_map> params; +}; diff --git a/tools/nodejs_api/src_cpp/include/node_connection.h b/tools/nodejs_api/src_cpp/include/node_connection.h new file mode 100644 index 00000000000..f51d9bf7625 --- /dev/null +++ b/tools/nodejs_api/src_cpp/include/node_connection.h @@ -0,0 +1,50 @@ +#include +#include +#include "main/kuzu.h" + +using namespace std; + +class NodeConnection : public Napi::ObjectWrap { + public: + static Napi::Object Init(Napi::Env env, Napi::Object exports); + NodeConnection(const Napi::CallbackInfo& info); + ~NodeConnection() = default; + + private: + Napi::Value GetConnection(const Napi::CallbackInfo& info); + Napi::Value TransferConnection(const Napi::CallbackInfo& info); + Napi::Value Execute(const Napi::CallbackInfo& info); + void SetMaxNumThreadForExec(const Napi::CallbackInfo& info); + Napi::Value GetNodePropertyNames(const Napi::CallbackInfo& info); + shared_ptr database; + shared_ptr connection; + uint64_t numThreads = 0; + + struct ThreadSafeConnectionContext { + ThreadSafeConnectionContext(Napi::Env env, shared_ptr & connection, + shared_ptr & database, uint64_t numThreads) : + deferred(Napi::Promise::Deferred::New(env)), + connection(connection), + database(database), + numThreads(numThreads){}; + + // Native Promise returned to JavaScript + Napi::Promise::Deferred deferred; + + uint64_t numThreads = 0; + + bool passed = true; + + shared_ptr connection; + kuzu::main::Connection * connection2; + shared_ptr database; + + // Native thread + std::thread nativeThread; + + Napi::ThreadSafeFunction tsfn; + }; + ThreadSafeConnectionContext * context; + static void threadEntry(ThreadSafeConnectionContext * context); + static void FinalizerCallback(Napi::Env env, void* finalizeData, ThreadSafeConnectionContext* context); +}; diff --git a/tools/nodejs_api/src_cpp/include/node_database.h b/tools/nodejs_api/src_cpp/include/node_database.h new file mode 100644 index 00000000000..2107a2d8901 --- /dev/null +++ b/tools/nodejs_api/src_cpp/include/node_database.h @@ -0,0 +1,16 @@ +#include +#include +#include "main/kuzu.h" + +using namespace std; + +class NodeDatabase : public Napi::ObjectWrap { +public: + static Napi::Object Init(Napi::Env env, Napi::Object exports); + NodeDatabase(const Napi::CallbackInfo& info); + ~NodeDatabase() = default; + friend class NodeConnection; + +private: + shared_ptr database; +}; diff --git a/tools/nodejs_api/src_cpp/include/node_query_result.h b/tools/nodejs_api/src_cpp/include/node_query_result.h new file mode 100644 index 00000000000..0d77da0d35f --- /dev/null +++ b/tools/nodejs_api/src_cpp/include/node_query_result.h @@ -0,0 +1,30 @@ +#ifndef KUZU_NODE_QUERY_RESULT_H +#define KUZU_NODE_QUERY_RESULT_H + +#include +#include +#include "main/kuzu.h" +#include "binder/bound_statement_result.h" +#include "planner/logical_plan/logical_plan.h" +#include "processor/result/factorized_table.h" + +using namespace std; + +class NodeQueryResult: public Napi::ObjectWrap { + public: + static Napi::Object Init(Napi::Env env, Napi::Object exports); + void SetQueryResult(shared_ptr & inputQueryResult); + NodeQueryResult(const Napi::CallbackInfo& info); + ~NodeQueryResult() = default; + + private: + void Close(const Napi::CallbackInfo& info); + Napi::Value All(const Napi::CallbackInfo& info); + Napi::Value Each(const Napi::CallbackInfo& info); + Napi::Value GetColumnDataTypes(const Napi::CallbackInfo& info); + Napi::Value GetColumnNames(const Napi::CallbackInfo& info); + shared_ptr queryResult; +}; + + +#endif diff --git a/tools/nodejs_api/src_cpp/include/tsfn_context.h b/tools/nodejs_api/src_cpp/include/tsfn_context.h new file mode 100644 index 00000000000..963c6270ee8 --- /dev/null +++ b/tools/nodejs_api/src_cpp/include/tsfn_context.h @@ -0,0 +1,52 @@ +#pragma once + +#include + +#include +#include "main/kuzu.h" +#include + +using namespace std; + +// Data structure representing our thread-safe function context. +struct TsfnContext { + enum Type { ALL, EACH }; + + TsfnContext(Napi::Env env, shared_ptr queryResult, Type type) : + deferred(Napi::Promise::Deferred::New(env)), + queryResult(queryResult), + type(type) {}; + + TsfnContext(Napi::Env env, shared_ptr queryResult, Type type, + Napi::Function doneCallback) : + deferred(Napi::Promise::Deferred::New(env)), + queryResult(queryResult), + type(type) { + _doneCallback = make_unique(Napi::FunctionReference(Napi::Persistent(doneCallback))); + _doneCallback->SuppressDestruct(); + }; + + Type type; + + // Native Promise returned to JavaScript + Napi::Promise::Deferred deferred; + + shared_ptr queryResult; + + // Native thread + std::thread nativeThread; + + Napi::ThreadSafeFunction tsfn; + + unique_ptr _doneCallback; + + // The thread entry point. This takes as its arguments the specific + // threadsafe-function context created inside the main thread. + static void threadEntry(TsfnContext* context); + + // The thread-safe function finalizer callback. This callback executes + // at destruction of thread-safe function, taking as arguments the finalizer + // data and threadsafe-function context. + static void FinalizerCallback(Napi::Env env, void* finalizeData, TsfnContext* context); +}; + diff --git a/tools/nodejs_api/src_cpp/include/util.h b/tools/nodejs_api/src_cpp/include/util.h new file mode 100644 index 00000000000..b00a575d170 --- /dev/null +++ b/tools/nodejs_api/src_cpp/include/util.h @@ -0,0 +1,20 @@ +#pragma once + +#include +#include +#include + +#include +#include "main/kuzu.h" + +using namespace std; + +class Util { + public: + static Napi::Object GetObjectFromProperties( + const vector>>& properties, + Napi::Env env); + static Napi::Value ConvertToNapiObject(const kuzu::common::Value& value, Napi::Env env); + static unordered_map> transformParameters(Napi::Array params); + static kuzu::common::Value transformNapiValue(Napi::Value val); +}; diff --git a/tools/nodejs_api/src_cpp/main.cpp b/tools/nodejs_api/src_cpp/main.cpp new file mode 100644 index 00000000000..e28642ba479 --- /dev/null +++ b/tools/nodejs_api/src_cpp/main.cpp @@ -0,0 +1,13 @@ +#include "include/node_connection.h" +#include "include/node_database.h" +#include "include/node_query_result.h" +#include + +Napi::Object InitAll(Napi::Env env, Napi::Object exports) { + NodeDatabase::Init(env, exports); + NodeConnection::Init(env, exports); + NodeQueryResult::Init(env, exports); + return exports; +} + +NODE_API_MODULE(addon, InitAll); diff --git a/tools/nodejs_api/src_cpp/node_connection.cpp b/tools/nodejs_api/src_cpp/node_connection.cpp new file mode 100644 index 00000000000..333bf4fc141 --- /dev/null +++ b/tools/nodejs_api/src_cpp/node_connection.cpp @@ -0,0 +1,135 @@ +#include "include/node_connection.h" + +#include "include/execute_async_worker.h" +#include "include/node_database.h" +#include "include/node_query_result.h" +#include "main/kuzu.h" +#include "include/util.h" + +using namespace kuzu::main; + +Napi::Object NodeConnection::Init(Napi::Env env, Napi::Object exports) { + Napi::HandleScope scope(env); + + Napi::Function t = DefineClass(env, "NodeConnection", { + InstanceMethod("getConnection", &NodeConnection::GetConnection), + InstanceMethod("transferConnection", &NodeConnection::TransferConnection), + InstanceMethod("execute", &NodeConnection::Execute), + InstanceMethod("setMaxNumThreadForExec", &NodeConnection::SetMaxNumThreadForExec), + InstanceMethod("getNodePropertyNames", &NodeConnection::GetNodePropertyNames), + }); + + exports.Set("NodeConnection", t); + return exports; +} + +NodeConnection::NodeConnection(const Napi::CallbackInfo& info) : Napi::ObjectWrap(info) { + Napi::Env env = info.Env(); + Napi::HandleScope scope(env); + + NodeDatabase * nodeDatabase = Napi::ObjectWrap::Unwrap(info[0].As()); + database = nodeDatabase->database; + numThreads = info[1].As().DoubleValue(); +} + +void NodeConnection::threadEntry(ThreadSafeConnectionContext * context) { + try { + context->connection = make_shared(context->database.get()); + if (context->numThreads > 0) { + context->connection->setMaxNumThreadForExec(context->numThreads); + } + } catch(const std::exception &exc){ + context->passed = false; + } + + context->tsfn.Release(); +} + +void NodeConnection::FinalizerCallback(Napi::Env env, void* finalizeData, ThreadSafeConnectionContext * context) { + context->nativeThread.join(); + + context->deferred.Resolve(Napi::Boolean::New(env, context->passed)); +} + +Napi::Value Fn(const Napi::CallbackInfo& info) { + Napi::Env env = info.Env(); + Napi::HandleScope scope(env); + + return Napi::String::New(env, "Hello World"); +} + + +Napi::Value NodeConnection::GetConnection(const Napi::CallbackInfo& info) { + Napi::Env env = info.Env(); + Napi::HandleScope scope(env); + + context = new ThreadSafeConnectionContext(env, connection, database, numThreads); + + context->tsfn = Napi::ThreadSafeFunction::New( + env, // Environment + Napi::Function::New(env), // JS function from caller + "ThreadSafeConnectionContext", // Resource name + 0, // Max queue size (0 = unlimited). + 1, // Initial thread count + context, // Context, + FinalizerCallback, // Finalizer + (void*)nullptr // Finalizer data + ); + context->nativeThread = std::thread(threadEntry, context); + + return context->deferred.Promise(); +} + +Napi::Value NodeConnection::TransferConnection(const Napi::CallbackInfo& info) { + Napi::Env env = info.Env(); + Napi::HandleScope scope(env); + + connection = std::move(context->connection); + delete context; + return info.Env().Undefined(); +} + +Napi::Value NodeConnection::Execute(const Napi::CallbackInfo& info) { + Napi::Env env = info.Env(); + Napi::HandleScope scope(env); + + std::string query = info[0].ToString(); + Napi::Function callback = info[1].As(); + NodeQueryResult * nodeQueryResult = Napi::ObjectWrap::Unwrap(info[2].As()); + try { + auto params = Util::transformParameters(info[3].As()); + ExecuteAsyncWorker* asyncWorker = new ExecuteAsyncWorker(callback, connection, query, nodeQueryResult, params); + asyncWorker->Queue(); + } catch(const std::exception &exc) { + Napi::Error::New(env, "Unsuccessful execute: " + std::string(exc.what())).ThrowAsJavaScriptException(); + } + return info.Env().Undefined(); +} + +void NodeConnection::SetMaxNumThreadForExec(const Napi::CallbackInfo& info) { + Napi::Env env = info.Env(); + Napi::HandleScope scope(env); + + uint64_t numThreads = info[0].ToNumber().DoubleValue(); + try { + this->connection->setMaxNumThreadForExec(numThreads); + } catch(const std::exception &exc) { + Napi::Error::New(env, "Unsuccessful setMaxNumThreadForExec: " + std::string(exc.what())).ThrowAsJavaScriptException(); + } + return; +} + +Napi::Value NodeConnection::GetNodePropertyNames(const Napi::CallbackInfo& info) { + Napi::Env env = info.Env(); + Napi::HandleScope scope(env); + + std::string tableName = info[0].ToString(); + std::string propertyNames; + try { + propertyNames = this->connection->getNodePropertyNames(tableName); + } catch(const std::exception &exc) { + Napi::Error::New(env, "Unsuccessful getNodePropertyNames: " + std::string(exc.what())).ThrowAsJavaScriptException(); + return Napi::Object::New(env); + } + return Napi::String::New(env, propertyNames);; +} diff --git a/tools/nodejs_api/src_cpp/node_database.cpp b/tools/nodejs_api/src_cpp/node_database.cpp new file mode 100644 index 00000000000..ce446c2eec2 --- /dev/null +++ b/tools/nodejs_api/src_cpp/node_database.cpp @@ -0,0 +1,35 @@ +#include "include/node_database.h" + +#include "main/kuzu.h" + +using namespace kuzu::main; + +Napi::Object NodeDatabase::Init(Napi::Env env, Napi::Object exports) { + Napi::HandleScope scope(env); + + Napi::Function t = DefineClass(env, "NodeDatabase", { + }); + + exports.Set("NodeDatabase", t); + return exports; +} + + +NodeDatabase::NodeDatabase(const Napi::CallbackInfo& info) : Napi::ObjectWrap(info) { + Napi::Env env = info.Env(); + Napi::HandleScope scope(env); + + std::string databasePath = info[0].ToString(); + std::int64_t bufferPoolSize = info[1].As().DoubleValue(); + + auto systemConfig = kuzu::main::SystemConfig(); + if (bufferPoolSize > 0) { + systemConfig.bufferPoolSize = bufferPoolSize; + } + + try { + this->database = make_shared(databasePath, systemConfig); + } catch(const std::exception &exc) { + Napi::Error::New(env, "Unsuccessful Database Initialization: " + std::string(exc.what())).ThrowAsJavaScriptException(); + } +} diff --git a/tools/nodejs_api/src_cpp/node_query_result.cpp b/tools/nodejs_api/src_cpp/node_query_result.cpp new file mode 100644 index 00000000000..75847aa98f5 --- /dev/null +++ b/tools/nodejs_api/src_cpp/node_query_result.cpp @@ -0,0 +1,123 @@ +#include "include/node_query_result.h" + +#include "main/kuzu.h" +#include "include/util.h" +#include "include/tsfn_context.h" +#include +#include + +using namespace kuzu::main; + +Napi::Object NodeQueryResult::Init(Napi::Env env, Napi::Object exports) { + Napi::HandleScope scope(env); + + Napi::Function t = DefineClass(env, "NodeQueryResult", { + InstanceMethod("close", &NodeQueryResult::Close), + InstanceMethod("all", &NodeQueryResult::All), + InstanceMethod("each", &NodeQueryResult::Each), + InstanceMethod("getColumnDataTypes", &NodeQueryResult::GetColumnDataTypes), + InstanceMethod("getColumnNames", &NodeQueryResult::GetColumnNames), + }); + + exports.Set("NodeQueryResult", t); + return exports; +} + +NodeQueryResult::NodeQueryResult(const Napi::CallbackInfo& info) : Napi::ObjectWrap(info) {} + +void NodeQueryResult::SetQueryResult(shared_ptr & inputQueryResult) { + this->queryResult = inputQueryResult; +} + +void NodeQueryResult::Close(const Napi::CallbackInfo& info) { + Napi::Env env = info.Env(); + Napi::HandleScope scope(env); + + try { + this->queryResult.reset(); + } catch(const std::exception &exc) { + Napi::Error::New(env, "Unsuccessful queryResult close: " + std::string(exc.what())).ThrowAsJavaScriptException(); + } + +} + +// Exported JavaScript function. Creates the thread-safe function and native +// thread. Promise is resolved in the thread-safe function's finalizer. +Napi::Value NodeQueryResult::All(const Napi::CallbackInfo& info) { + Napi::Env env = info.Env(); + Napi::HandleScope scope(env); + + // Construct context data + auto testData = new TsfnContext(env, queryResult, TsfnContext::ALL); + + // Create a new ThreadSafeFunction. + testData->tsfn = Napi::ThreadSafeFunction::New( + env, // Environment + info[0].As(), // JS function from caller + "TSFN", // Resource name + 0, // Max queue size (0 = unlimited). + 1, // Initial thread count + testData, // Context, + TsfnContext::FinalizerCallback,// Finalizer + (void*)nullptr // Finalizer data + ); + testData->nativeThread = std::thread(TsfnContext::threadEntry, testData); + + // Return the deferred's Promise. This Promise is resolved in the thread-safe + // function's finalizer callback. + return testData->deferred.Promise(); +} + + +// Exported JavaScript function. Creates the thread-safe function and native +// thread. Promise is resolved in the thread-safe function's finalizer. +Napi::Value NodeQueryResult::Each(const Napi::CallbackInfo& info) { + Napi::Env env = info.Env(); + Napi::HandleScope scope(env); + + // Construct context data + auto testData = new TsfnContext(env, queryResult, TsfnContext::TsfnContext::EACH, info[1].As()); + + // Create a new ThreadSafeFunction. + testData->tsfn = Napi::ThreadSafeFunction::New( + env, // Environment + info[0].As(), // JS function from caller + "TSFN", // Resource name + 0, // Max queue size (0 = unlimited). + 1, // Initial thread count + testData, // Context, + TsfnContext::FinalizerCallback,// Finalizer + (void * ) nullptr // Finalizer data + ); + testData->nativeThread = std::thread(TsfnContext::threadEntry, testData); + + // Return the deferred's Promise. This Promise is resolved in the thread-safe + // function's finalizer callback. + return testData->deferred.Promise(); +} + +Napi::Value NodeQueryResult::GetColumnDataTypes(const Napi::CallbackInfo& info) { + Napi::Env env = info.Env(); + Napi::HandleScope scope(env); + + auto columnDataTypes = queryResult->getColumnDataTypes(); + Napi::Array arr = Napi::Array::New(env, columnDataTypes.size()); + + for (auto i = 0u; i < columnDataTypes.size(); ++i) { + arr.Set(i, kuzu::common::Types::dataTypeToString(columnDataTypes[i])); + } + return arr; +} + +Napi::Value NodeQueryResult::GetColumnNames(const Napi::CallbackInfo& info) { + Napi::Env env = info.Env(); + Napi::HandleScope scope(env); + + auto columnNames = queryResult->getColumnNames(); + Napi::Array arr = Napi::Array::New(env, columnNames.size()); + + for (auto i = 0u; i < columnNames.size(); ++i) { + arr.Set(i, columnNames[i]); + } + return arr; +} \ No newline at end of file diff --git a/tools/nodejs_api/src_cpp/tsfn_context.cpp b/tools/nodejs_api/src_cpp/tsfn_context.cpp new file mode 100644 index 00000000000..868a2d13782 --- /dev/null +++ b/tools/nodejs_api/src_cpp/tsfn_context.cpp @@ -0,0 +1,47 @@ +#include "include/tsfn_context.h" +#include "include/util.h" + +void TsfnContext::threadEntry(TsfnContext* context) { + auto callback = [](Napi::Env env, Napi::Function jsCallback, TsfnContext * context) { + Napi::Array arr = Napi::Array::New(env); + size_t i = 0; + auto columnNames = context->queryResult->getColumnNames(); + while (context->queryResult->hasNext()) { + auto row = context->queryResult->getNext(); + Napi::Object rowArray = Napi::Object::New(env); + for (size_t j = 0; j < row->len(); j++) { + Napi::Value val = Util::ConvertToNapiObject(*row->getValue(j), env); + rowArray.Set( (j >= columnNames.size()) ? "" : columnNames[j], val); + } + if (context->type == TsfnContext::EACH) { jsCallback.Call({env.Null(), rowArray}); } + else { arr.Set(i++, rowArray); } + } + if (context->type == TsfnContext::ALL) { jsCallback.Call({env.Null(), arr}); } + else if (context->type == TsfnContext::EACH) { context->_doneCallback->Call({}); } + }; + + auto errorCallback = []( Napi::Env env, Napi::Function jsCallback ) { + Napi::Error error = Napi::Error::New(env, "Unsuccessful async all callback"); + jsCallback.Call({error.Value(), env.Undefined()}); + }; + + napi_status status = + context->tsfn.BlockingCall(context, callback); + + if (status != napi_ok) { + Napi::Error::Fatal( + "ThreadEntry", + "Napi::ThreadSafeNapi::Function.BlockingCall() failed"); + context->tsfn.BlockingCall( errorCallback ); + } + + context->tsfn.Release(); +} + +void TsfnContext::FinalizerCallback(Napi::Env env, void* finalizeData, TsfnContext* context) { + context->nativeThread.join(); + + context->deferred.Resolve(Napi::Boolean::New(env, true)); + delete context; +} + diff --git a/tools/nodejs_api/src_cpp/util.cpp b/tools/nodejs_api/src_cpp/util.cpp new file mode 100644 index 00000000000..8b486da8c1d --- /dev/null +++ b/tools/nodejs_api/src_cpp/util.cpp @@ -0,0 +1,142 @@ +#include "include/util.h" + +using namespace kuzu::common; + +Napi::Object Util::GetObjectFromProperties(const vector>>& properties, Napi::Env env) { + Napi::Object nodeObj = Napi::Object::New(env); + for (auto i = 0u; i < properties.size(); ++i) { + auto& [name, value] = properties[i]; + nodeObj.Set(name, Util::ConvertToNapiObject(*value, env)); + } + return nodeObj; +} + +Napi::Value Util::ConvertToNapiObject(const kuzu::common::Value& value, Napi::Env env) { + if (value.isNull()) { + return Napi::Value(); + } + auto dataType = value.getDataType(); + switch (dataType.typeID) { + case kuzu::common::BOOL: { + return Napi::Boolean::New(env, value.getValue()); + } + case kuzu::common::INT64: { + return Napi::Number::New(env, value.getValue()); + } + case kuzu::common::DOUBLE: { + return Napi::Number::New(env, value.getValue()); + } + case kuzu::common::STRING: { + return Napi::String::New(env, value.getValue()); + } + case kuzu::common::DATE: { + auto dateVal = value.getValue(); + auto milliseconds = ((int64_t)dateVal.days) * (kuzu::common::Interval::MICROS_PER_DAY) / kuzu::common::Interval::MICROS_PER_MSEC; + return Napi::Date::New(env, milliseconds); + } + case kuzu::common::TIMESTAMP: { + auto timestampVal = value.getValue(); + auto milliseconds = timestampVal.value / kuzu::common::Interval::MICROS_PER_MSEC; + return Napi::Date::New(env, milliseconds); + } + case kuzu::common::INTERVAL: { + auto intervalVal = value.getValue(); + auto days = Interval::DAYS_PER_MONTH * intervalVal.months + intervalVal.days; + Napi::Object intervalObj = Napi::Object::New(env); + intervalObj.Set("days", Napi::Number::New(env, days)); + intervalObj.Set("microseconds", Napi::Number::New(env, intervalVal.micros)); + return intervalObj; + } + case kuzu::common::VAR_LIST: { + auto& listVal = value.getListValReference(); + Napi::Array arr = Napi::Array::New(env, listVal.size()); + for (auto i = 0u; i < listVal.size(); ++i) { + arr.Set(i, ConvertToNapiObject(*listVal[i], env)); + } + return arr; + } + case kuzu::common::NODE: { + auto nodeVal = value.getValue(); + Napi::Object nodeObj = GetObjectFromProperties(nodeVal.getProperties(), env); + + nodeObj.Set("_label", Napi::String::New(env, nodeVal.getLabelName())); + + Napi::Object nestedObj = Napi::Object::New(env); + nestedObj.Set("offset", Napi::Number::New(env, nodeVal.getNodeID().offset)); + nestedObj.Set("table", Napi::Number::New(env, nodeVal.getNodeID().tableID)); + nodeObj.Set("_id", nestedObj); + + return nodeObj; + } + case kuzu::common::REL: { + auto relVal = value.getValue(); + Napi::Object nodeObj = GetObjectFromProperties(relVal.getProperties(), env); + + Napi::Object nestedObjSrc = Napi::Object::New(env); + nestedObjSrc.Set("offset", Napi::Number::New(env, relVal.getSrcNodeID().offset)); + nestedObjSrc.Set("table", Napi::Number::New(env, relVal.getSrcNodeID().tableID)); + nodeObj.Set("_src", nestedObjSrc); + + Napi::Object nestedObjDst = Napi::Object::New(env); + nestedObjDst.Set("offset", Napi::Number::New(env, relVal.getDstNodeID().offset)); + nestedObjDst.Set("table", Napi::Number::New(env, relVal.getDstNodeID().tableID)); + nodeObj.Set("_dst", nestedObjDst); + + return nodeObj; + } + case kuzu::common::INTERNAL_ID: { + auto internalIDVal = value.getValue(); + Napi::Object nestedObjDst = Napi::Object::New(env); + nestedObjDst.Set("offset", Napi::Number::New(env, internalIDVal.offset)); + nestedObjDst.Set("table", Napi::Number::New(env, internalIDVal.tableID)); + return nestedObjDst; + } + default: + Napi::TypeError::New(env, "Unsupported type: " + kuzu::common::Types::dataTypeToString(dataType)); + } + return Napi::Value(); +} + +unordered_map> Util::transformParameters(Napi::Array params) { + unordered_map> result; + for (size_t i = 0; i < params.Length(); i++){ + Napi::Array param = params.Get(i).As(); + if (param.Length()!=2) { + throw runtime_error("Each parameter must be in the form of "); + } else if (!param.Get(uint32_t(0)).IsString()) { + throw runtime_error("Parameter name must be of type string"); + } + string name = param.Get(uint32_t(0)).ToString(); + auto transformedVal = transformNapiValue(param.Get(uint32_t(1))); + result.insert({name, make_shared(transformedVal)}); + } + return result; +} + +kuzu::common::Value Util::transformNapiValue(Napi::Value val) { + if (val.IsBoolean()) { + bool temp = val.ToBoolean(); + return Value::createValue(temp); + } else if (val.IsNumber()) { + double temp = val.ToNumber().DoubleValue(); + if (!isnan(temp) && (temp >= INT_MIN) && (temp <= INT_MAX) && ((double) ((int) temp) == temp)) { + return Value::createValue(temp); + } else { + return Value::createValue(temp); + } + } else if (val.IsString()) { + string temp = val.ToString(); + return Value::createValue(temp); + } else if (val.IsDate()) { + double dateMilliseconds = val.As().ValueOf(); + auto time = kuzu::common::Timestamp::FromEpochMs((int64_t) dateMilliseconds); + if (kuzu::common::Timestamp::trunc(kuzu::common::DatePartSpecifier::DAY, time) + == time) { + return Value::createValue(kuzu::common::Timestamp::GetDate(time)); + } + return Value::createValue(time); + } else { + throw runtime_error("Unknown parameter type"); + } +} + diff --git a/tools/nodejs_api/src_nodejs/connection.js b/tools/nodejs_api/src_nodejs/connection.js new file mode 100644 index 00000000000..2e9dc9a47db --- /dev/null +++ b/tools/nodejs_api/src_nodejs/connection.js @@ -0,0 +1,95 @@ +const kuzu = require("../Release/kuzujs.node"); +const QueryResult = require("./queryResult.js"); + +class Connection { + #connection; + #database; + #initialized; + constructor(database, numThreads = 0) { + if (typeof database !== "object" || database.constructor.name !== "Database"){ + throw new Error("Connection constructor requires a valid database object"); + } else if (typeof numThreads !== "number" || !Number.isInteger(numThreads)) { + throw new Error("numThreads is not a valid integer in the Connection Constructor"); + } + this.#database = database; + this.#connection = new kuzu.NodeConnection(database.database, numThreads); + this.#initialized = false; + } + + async getConnection() { + if (this.#initialized) { + return this.#connection; + } + const promiseResult = await this.#connection.getConnection(); + if (promiseResult) { + this.#connection.transferConnection(); + this.#initialized = true; + } else { + throw new Error("GetConnection Failed"); + } + return this.#connection; + } + + async execute(query, opts = {}) { + const connection = await this.getConnection(); + const optsKeys = Object.keys(opts); + if (typeof opts !== "object") { + throw new Error("optional opts in execute must be an object"); + } else if (optsKeys.length > 2) { + throw new Error("opts can only have optional fields 'callback' and/or 'params'"); + } + + const validSet = new Set(optsKeys.concat(['callback', 'params'])); + if (validSet.size > 2) { + throw new Error("opts has at least 1 invalid field: it can only have optional fields 'callback' and/or 'params'"); + } + + let params = []; + if ('params' in opts) { + params = opts['params']; + } + if (typeof query !== "string" || !Array.isArray(params)) { + throw new Error("execute takes a string query and optional parameter array"); + } + + const nodeQueryResult = new kuzu.NodeQueryResult(); + const queryResult = new QueryResult(this, nodeQueryResult); + if ('callback' in opts) { + const callback = opts['callback']; + if (typeof callback !== "function" || callback.length > 2) { + throw new Error("if execute is given a callback, it must at most take 2 arguments: (err, result)"); + } + connection.execute(query, err => { + callback(err, queryResult); + }, nodeQueryResult, params); + } else { + return new Promise((resolve, reject) => { + connection.execute(query, err => { + if (err) { + return reject(err); + } + return resolve(queryResult); + }, nodeQueryResult, params); + }) + } + } + + async setMaxNumThreadForExec(numThreads) { + const connection = await this.getConnection(); + if (typeof numThreads !== "number" || !Number.isInteger(numThreads)) { + throw new Error("setMaxNumThreadForExec needs an integer numThreads as an argument"); + } + connection.setMaxNumThreadForExec(numThreads); + } + + async getNodePropertyNames(tableName) { + const connection = await this.getConnection(); + if (typeof tableName !== "string") { + throw new Error("getNodePropertyNames needs a string tableName as an argument"); + } + return connection.getNodePropertyNames(tableName); + } + +} + +module.exports = Connection diff --git a/tools/nodejs_api/src_nodejs/database.js b/tools/nodejs_api/src_nodejs/database.js new file mode 100644 index 00000000000..04d1a1590a1 --- /dev/null +++ b/tools/nodejs_api/src_nodejs/database.js @@ -0,0 +1,12 @@ +const kuzu = require("../Release/kuzujs.node"); +class Database { + database; + constructor(databasePath, bufferManagerSize = 0) { + if (typeof databasePath !== "string" || typeof bufferManagerSize !== "number" || !Number.isInteger(bufferManagerSize)){ + throw new Error("Database constructor requires a databasePath string and optional bufferManagerSize integer as argument(s)"); + } + this.database = new kuzu.NodeDatabase(databasePath, bufferManagerSize); + } +} + +module.exports = Database diff --git a/tools/nodejs_api/src_nodejs/index.js b/tools/nodejs_api/src_nodejs/index.js new file mode 100644 index 00000000000..b55f32bf919 --- /dev/null +++ b/tools/nodejs_api/src_nodejs/index.js @@ -0,0 +1,4 @@ +const QueryResult = require("./queryResult.js"); +const Database = require("./database.js"); +const Connection = require("./connection.js"); +module.exports = {Database, Connection, QueryResult} diff --git a/tools/nodejs_api/src_nodejs/queryResult.js b/tools/nodejs_api/src_nodejs/queryResult.js new file mode 100644 index 00000000000..cd8866b139c --- /dev/null +++ b/tools/nodejs_api/src_nodejs/queryResult.js @@ -0,0 +1,62 @@ +class QueryResult { + #connection + #database; + #queryResult + constructor(connection, queryResult) { + if (typeof connection !== "object" || connection.constructor.name !== "Connection"){ + throw new Error("Connection constructor requires a 'Connection' object as an argument"); + } else if (typeof queryResult !== "object" || queryResult.constructor.name !== "NodeQueryResult"){ + throw new Error("QueryResult constructor requires a 'NodeQueryResult' object as an argument"); + } + this.#connection = connection; + this.#queryResult = queryResult; + } + + async each(rowCallback, doneCallback) { + return new Promise(async (resolve, reject) => { + if (typeof rowCallback !== 'function' || rowCallback.length > 2 || typeof doneCallback !== 'function' || doneCallback.length !== 0) { + return reject(new Error("each must have at most 2 callbacks: rowCallback at most takes 2 arguments: (err, result), doneCallback takes none")); + } + this.#queryResult.each(rowCallback, doneCallback).catch(err => { + return reject(err) + }); + }); + } + + async all(opts = {} ) { + return new Promise(async (resolve, reject) => { + const optsKeys = Object.keys(opts); + const validSet = new Set(optsKeys.concat(['callback'])); + if (validSet.size > 1) { + return reject(new Error("opts has at least 1 invalid field: it can only have optional field 'callback'")); + } else if ('callback' in opts) { + const callback = opts['callback']; + if (typeof callback !== 'function' || callback.length > 2) { + return reject(new Error("if execute is given a callback, it at most can take 2 arguments: (err, result)")); + } + this.#queryResult.all(callback).catch(err => { + return reject(err); + }); + } else { + this.#queryResult.all((err, result) => { + if (err) { + return reject(err); + } + return resolve(result); + }).catch(err => { + return reject(err); + }); + } + }) + } + + getColumnDataTypes() { + return this.#queryResult.getColumnDataTypes(); + } + + getColumnNames() { + return this.#queryResult.getColumnNames(); + } +} + +module.exports = QueryResult diff --git a/tools/nodejs_api/test/common.js b/tools/nodejs_api/test/common.js new file mode 100644 index 00000000000..cd375ed1aa3 --- /dev/null +++ b/tools/nodejs_api/test/common.js @@ -0,0 +1,53 @@ +global.chai = require("chai"); +global.assert = chai.assert; +global.expect = chai.expect; +chai.should(); +chai.config.includeStack = true; + +process.env.NODE_ENV = "test"; +global.kuzu = require("../build/kuzu"); + +const tmp = require("tmp"); +const initTests = async () => { + const dbPath = await new Promise((resolve, reject) => { + tmp.dir({ unsafeCleanup: true }, (err, path, _) => { + if (err) { + return reject(err); + } + return resolve(path); + }); + }); + + const db = new kuzu.Database(dbPath, 1 << 28 /* 256MB */); + const conn = new kuzu.Connection(db, 4); + await conn.execute(`CREATE NODE TABLE person (ID INT64, fName STRING, + gender INT64, isStudent BOOLEAN, isWorker BOOLEAN, + age INT64, eyeSight DOUBLE, birthdate DATE, + registerTime TIMESTAMP, lastJobDuration INTERVAL, + workedHours INT64[], usedNames STRING[], + courseScoresPerTerm INT64[][], grades INT64[], + height DOUBLE, PRIMARY KEY (ID))`); + await conn.execute(`CREATE REL TABLE knows (FROM person TO person, + date DATE, meetTime TIMESTAMP, validInterval INTERVAL, + comments STRING[], MANY_MANY);`); + await conn.execute(`CREATE NODE TABLE organisation (ID INT64, name STRING, + orgCode INT64, mark DOUBLE, score INT64, history STRING, + licenseValidInterval INTERVAL, rating DOUBLE, + PRIMARY KEY (ID))`); + await conn.execute(`CREATE REL TABLE workAt (FROM person TO organisation, + year INT64, listdec DOUBLE[], height DOUBLE, MANY_ONE)`); + + await conn.execute( + `COPY person FROM "../../dataset/tinysnb/vPerson.csv" (HEADER=true)` + ); + await conn.execute(`COPY knows FROM "../../dataset/tinysnb/eKnows.csv"`); + await conn.execute( + `COPY organisation FROM "../../dataset/tinysnb/vOrganisation.csv"` + ); + await conn.execute(`COPY workAt FROM "../../dataset/tinysnb/eWorkAt.csv"`); + global.dbPath = dbPath + global.db = db; + global.conn = conn; +}; + +global.initTests = initTests; diff --git a/tools/nodejs_api/test/test.js b/tools/nodejs_api/test/test.js new file mode 100644 index 00000000000..4d50f5c5eed --- /dev/null +++ b/tools/nodejs_api/test/test.js @@ -0,0 +1,17 @@ +require("./common.js"); + +const importTest = (name, path) => { + describe(name, () => { + require(path); + }); +}; + +describe("kuzu", () => { + before(() => { + return initTests(); + }); + importTest("datatype", "./testDatatype.js"); + importTest("exception", "./testException.js"); + importTest("getHeader", "./testGetHeader.js"); + importTest("parameter", "./testParameter.js"); +}); diff --git a/tools/nodejs_api/test/testDatatype.js b/tools/nodejs_api/test/testDatatype.js new file mode 100644 index 00000000000..e13db7c842d --- /dev/null +++ b/tools/nodejs_api/test/testDatatype.js @@ -0,0 +1,232 @@ +const { assert } = require("chai"); + +describe("BOOL", function () { + it("should return Boolean for BOOL types as promise", function (done) { + conn + .execute("MATCH (a:person) WHERE a.ID = 0 RETURN a.isStudent;") + .then((queryResult) => { + return queryResult.all(); + }) + .then((result) => { + assert.equal(result.length, 1); + assert.equal(Object.keys(result[0]).length, 1) + assert.equal("a.isStudent" in result[0], true) + assert.equal(typeof result[0]["a.isStudent"], "boolean"); + assert.equal(result[0]["a.isStudent"], true); + done(); + }); + }); + it("should return Boolean for BOOL types as callback", function (done) { + conn + .execute("MATCH (a:person) WHERE a.ID = 0 RETURN a.isStudent;", {"callback" : async function (err, queryResult) { + assert.equal(err, null); + await queryResult.all({ + "callback": (err, result) => { + assert.equal(result.length, 1); + assert.equal(Object.keys(result[0]).length, 1) + assert.equal("a.isStudent" in result[0], true) + assert.equal(typeof result[0]["a.isStudent"], "boolean"); + assert.equal(result[0]["a.isStudent"], true); + done(); + } + }); + } + }); + }); +}); + + +describe("INT", function () { + it("should return Integer for INT types as promise", function (done) { + conn + .execute("MATCH (a:person) WHERE a.ID = 0 RETURN a.age;") + .then((queryResult) => { + return queryResult.all(); + }) + .then((result) => { + assert.equal(result.length, 1); + assert.equal(Object.keys(result[0]).length, 1) + assert.equal("a.age" in result[0], true) + assert.equal(typeof result[0]["a.age"], "number"); + assert.equal(result[0]["a.age"], 35); + done(); + }); + }); +}); + +describe("DOUBLE", function () { + it("should return Double for DOUBLE types as promise", function (done) { + conn + .execute("MATCH (a:person) WHERE a.ID = 0 RETURN a.eyeSight;") + .then((queryResult) => { + return queryResult.all(); + }) + .then((result) => { + assert.equal(result.length, 1); + assert.equal(Object.keys(result[0]).length, 1) + assert.equal("a.eyeSight" in result[0], true) + assert.equal(typeof result[0]["a.eyeSight"], "number"); + assert.equal(result[0]["a.eyeSight"], 5.0); + done(); + }); + }); +}); + +describe("STRING", function () { + it("should return String for STRING types as promise", function (done) { + conn + .execute("MATCH (a:person) WHERE a.ID = 0 RETURN a.fName;") + .then((queryResult) => { + return queryResult.all(); + }) + .then((result) => { + assert.equal(result.length, 1); + assert.equal(Object.keys(result[0]).length, 1) + assert.equal("a.fName" in result[0], true) + assert.equal(typeof result[0]["a.fName"], "string"); + assert.equal(result[0]["a.fName"], "Alice"); + done(); + }); + }); +}); + +describe("DATE", function () { + it("should return Date for DATE types as promise", function (done) { + conn + .execute("MATCH (a:person) WHERE a.ID = 0 RETURN a.birthdate;") + .then((queryResult) => { + return queryResult.all(); + }) + .then((result) => { + assert.equal(result.length, 1); + assert.equal(Object.keys(result[0]).length, 1) + assert.equal("a.birthdate" in result[0], true) + assert.equal(typeof result[0]["a.birthdate"], "object"); + assert.equal(result[0]["a.birthdate"].getTime(), new Date(1900,0,1,-5).getTime()); + done(); + }); + }); +}); + + +describe("TIMESTAMP", function () { + it("should return Timestamp for TIMESTAMP types as promise", function (done) { + conn + .execute("MATCH (a:person) WHERE a.ID = 0 RETURN a.registerTime;") + .then((queryResult) => { + return queryResult.all(); + }) + .then((result) => { + assert.equal(result.length, 1); + assert.equal(Object.keys(result[0]).length, 1) + assert.equal("a.registerTime" in result[0], true) + assert.equal(typeof result[0]["a.registerTime"], "object"); + assert.equal(result[0]["a.registerTime"].getTime(), new Date(2011,7,20,7,25,30).getTime()); + done(); + }); + }); +}); + + +describe("INTERVAL", function () { + it("should return Interval for INTERVAL types as promise", function (done) { + conn + .execute("MATCH (a:person) WHERE a.ID = 0 RETURN a.lastJobDuration;") + .then((queryResult) => { + return queryResult.all(); + }) + .then((result) => { + assert.equal(result.length, 1); + assert.equal(Object.keys(result[0]).length, 1) + assert.equal("a.lastJobDuration" in result[0], true) + assert.equal(typeof result[0]["a.lastJobDuration"], "object"); + assert.equal(Object.keys(result[0]["a.lastJobDuration"]).length, 2); + assert.equal("days" in result[0]["a.lastJobDuration"], true); + assert.equal("microseconds" in result[0]["a.lastJobDuration"], true); + assert.equal(typeof result[0]["a.lastJobDuration"]["days"], "number"); + assert.equal(typeof result[0]["a.lastJobDuration"]["microseconds"], "number"); + assert.equal(result[0]["a.lastJobDuration"]["days"], 1082); + assert.equal(result[0]["a.lastJobDuration"]["microseconds"], 46920000000); + done(); + }); + }); +}); + +describe("LIST", function () { + it("should return List for LIST types as promise", function (done) { + conn + .execute("MATCH (a:person) WHERE a.ID = 0 RETURN a.courseScoresPerTerm;") + .then((queryResult) => { + return queryResult.all(); + }) + .then((result) => { + assert.equal(result.length, 1); + assert.equal(Object.keys(result[0]).length, 1) + assert.equal("a.courseScoresPerTerm" in result[0], true) + assert.equal(typeof result[0]["a.courseScoresPerTerm"], "object"); + assert.equal(result[0]["a.courseScoresPerTerm"].length, 2); + assert.equal(result[0]["a.courseScoresPerTerm"][0].length, 2); + assert.equal(result[0]["a.courseScoresPerTerm"][1].length, 3); + assert.equal(JSON.stringify(result[0]["a.courseScoresPerTerm"][0]), JSON.stringify([10,8])); + assert.equal(JSON.stringify(result[0]["a.courseScoresPerTerm"][1]), JSON.stringify([6,7,8])); + done(); + }); + }); +}); + +describe("NODE", function () { + it("should return Node for NODE types as promise", function (done) { + conn + .execute("MATCH (a:person) WHERE a.ID = 0 RETURN a;") + .then((queryResult) => { + return queryResult.all(); + }) + .then((result) => { + assert.equal(result.length, 1); + assert.equal(Object.keys(result[0]).length, 1) + assert.equal("a" in result[0], true) + result = result[0]["a"] + assert.equal(result["_label"], "person"); + assert.equal(result["ID"], 0); + assert.equal(result["gender"], 1); + assert.equal(result["isStudent"], true); + assert.equal(result["age"], 35); + assert.equal(result["eyeSight"], 5.0); + assert.equal(result["fName"], "Alice"); + assert.equal(result["birthdate"].getTime(), new Date(1900,0,1,-5).getTime()); + assert.equal(result["registerTime"].getTime(), new Date(2011,7,20,7,25,30).getTime()); + assert.equal(result["lastJobDuration"]["days"], 1082); + assert.equal(result["lastJobDuration"]["microseconds"], 46920000000); + assert.equal(JSON.stringify(result["courseScoresPerTerm"][0]), JSON.stringify([10,8])); + assert.equal(JSON.stringify(result["courseScoresPerTerm"][1]), JSON.stringify([6,7,8])); + assert.equal(result["usedNames"], "Aida"); + assert.equal(result["_id"]["offset"], 0); + assert.equal(result["_id"]["table"], 0); + done(); + }); + }); +}); + +describe("REL", function () { + it("should return Rel for REL types as promise", function (done) { + conn + .execute("MATCH (p:person)-[r:workAt]->(o:organisation) WHERE p.ID = 5 RETURN p, r, o") + .then((queryResult) => { + return queryResult.all(); + }) + .then((n) => { + n = n[0] + const p = n["p"]; + const r = n["r"]; + const o = n["o"]; + assert.equal(p['_label'], 'person') + assert.equal(p['ID'], 5) + assert.equal(o['_label'], 'organisation') + assert.equal(o['ID'], 6) + assert.equal(r['year'], 2010) + assert.equal(JSON.stringify(r['_src']), JSON.stringify(p['_id'])) + assert.equal(JSON.stringify(r['_dst']), JSON.stringify(o['_id'])) + done(); + }); + }); +}); diff --git a/tools/nodejs_api/test/testException.js b/tools/nodejs_api/test/testException.js new file mode 100644 index 00000000000..c36b7b7b55e --- /dev/null +++ b/tools/nodejs_api/test/testException.js @@ -0,0 +1,229 @@ +const { assert } = require("chai"); + +describe("DATABASE CONSTRUCTOR", function () { + it('should create database with no bufferManagerSize argument', async function () { + const testDb = new kuzu.Database(dbPath); + const testConn = new kuzu.Connection(testDb); + testConn.execute(`CREATE NODE TABLE testSmallPerson (ID INT64, PRIMARY KEY (ID))`).then(queryResult => { + queryResult.all().then(result => { + assert.equal(result.length, 1) + assert.equal(result[0]['outputMsg'] === "NodeTable: testSmallPerson has been created.", true) + }) + }) + }); + + it('should throw error when dbPath is the wrong type', async function () { + try { + new kuzu.Database(5); + } + catch (err) { + assert.equal(err.message, "Database constructor requires a databasePath string and optional bufferManagerSize integer as argument(s)") + } + }); + + it('should throw error when bufferManagerSize is the wrong type', async function () { + try { + new kuzu.Database(dbPath, 4.5); + } + catch (err) { + assert.equal(err.message, "Database constructor requires a databasePath string and optional bufferManagerSize integer as argument(s)") + } + }); +}); + +describe("CONNECTION CONSTRUCTOR", function () { + it('should create connection with no numThreads argument', async function () { + const testConn = new kuzu.Connection(db); + testConn.execute(`CREATE NODE TABLE testSmallPerson (ID INT64, PRIMARY KEY (ID))`).then(queryResult => { + queryResult.all().then(result => { + assert.equal(result.length, 1) + assert.equal(result[0]['outputMsg'] === "NodeTable: testSmallPerson has been created.", true) + }) + }) + }); + + it('should throw error when database object is invalid', async function () { + try { + new kuzu.Connection("db"); + } + catch (err) { + assert.equal(err.message, "Connection constructor requires a valid database object") + } + }); + + it('should throw error when numThreads is double', async function () { + try { + new kuzu.Connection(db, 5.2); + } + catch (err) { + assert.equal(err.message, "numThreads is not a valid integer in the Connection Constructor") + } + }); +}); + +describe("EXECUTE", function () { + it('query opts should be object (map)', async function () { + try { + const prom = await conn.execute("MATCH (a:person) RETURN a.isStudent", ""); + } catch (err) { + assert.equal(err.message, "optional opts in execute must be an object"); + } + }); + + it('query opts too many fields', async function () { + try { + const prom = await conn.execute("MATCH (a:person) RETURN a.isStudent", {'james': {}, 'hi': {}, 'bye': {}}); + } catch (err) { + assert.equal(err.message, "opts can only have optional fields 'callback' and/or 'params'"); + } + }); + + it('query opts invalid field', async function () { + try { + const prom = await conn.execute("MATCH (a:person) RETURN a.isStudent", {'james': {}}); + } catch (err) { + assert.equal(err.message, "opts has at least 1 invalid field: it can only have optional fields 'callback' and/or 'params'"); + } + }); + + it('query field should be string', async function () { + try { + await conn.execute(5); + } catch (err) { + assert.equal(err.message, "execute takes a string query and optional parameter array"); + } + }); + + it('query param in opts should be array', async function () { + try { + await conn.execute("MATCH (a:person) RETURN a.isStudent", {"params": {}}); + } catch (err) { + assert.equal(err.message, "execute takes a string query and optional parameter array"); + } + }); + + it('query callback in opts should be function', async function () { + try { + await conn.execute("MATCH (a:person) RETURN a.isStudent", {"callback": ""}); + } catch (err) { + assert.equal(err.message, "if execute is given a callback, it must at most take 2 arguments: (err, result)"); + } + }); + + it('should throw error when query refers to non-existing field', async function (done) { + try { + conn.execute("MATCH (a:person) RETURN a.dummy;").catch(err => { + throw new Error(err); + }) + } catch (err) { + assert.equal(err.message, "Binder exception: Cannot find property dummy for a."); + } + done() + }); +}); + +describe("SETMAXTHREADS", function () { + it('decrease max threads', async function () { + await conn.setMaxNumThreadForExec(2); + }); + + it('increase max threads', async function () { + await conn.setMaxNumThreadForExec(5); + }); + + it('should only take number as argument', function () { + try { + conn.setMaxNumThreadForExec("hi"); + } catch (err) { + assert.equal(err.message, "setMaxNumThreadForExec needs an integer numThreads as an argument");; + } + }); + + it('should only take integer as argument', function () { + try { + conn.setMaxNumThreadForExec(5.6); + } catch (err) { + assert.equal(err.message, "setMaxNumThreadForExec needs an integer numThreads as an argument");; + } + }); +}); + +describe("GETNODEPROPERTYNAMES", function () { + it('should return valid property names for valid tableName', async function () { + const propertyNames = await conn.getNodePropertyNames("organisation"); + assert.equal(typeof propertyNames, "string"); + assert.equal(propertyNames.length>0, true); + }); + + it('should throw error for non string argument', function () { + try { + conn.getNodePropertyNames(2); + } catch (err) { + assert.equal(err.message, "getNodePropertyNames needs a string tableName as an argument"); + } + }); +}); + + +describe("QUERYRESULT CONSTRUCTOR", function () { + it('should throw error when invalid queryresult passed in', function () { + try { + new kuzu.QueryResult(conn, {}); + } catch (err) { + assert.equal(err.message, "QueryResult constructor requires a 'NodeQueryResult' object as an argument"); + } + }); + + it('should throw error when invalid connection passed in', function () { + try { + new kuzu.QueryResult({}, {}); + } catch (err) { + assert.equal(err.message, "Connection constructor requires a 'Connection' object as an argument"); + } + }); +}); + +describe("EACH", function () { + it('should throw error when doneCallback to each is invalid', function (done) { + conn.execute("MATCH (a:person) RETURN a.isStudent").then(queryResult => { + queryResult.each((err, result) => { + console.log(result); + }, (err) => console.log(err)).catch(err => { + assert.equal(err.message, "each must have at most 2 callbacks: rowCallback at most takes 2 arguments: (err, result), doneCallback takes none"); + done(); + }); + }) + }) + +}); + +describe("ALL", function () { + it('should throw error when invalid field in opts', function (done) { + conn.execute("MATCH (a:person) RETURN a.isStudent").then(queryResult => { + queryResult.all({'james': {}}).catch(err => { + assert.equal(err.message, "opts has at least 1 invalid field: it can only have optional field 'callback'"); + done(); + }); + }) + }) + + it('should throw error when callback is invalid type', function (done) { + conn.execute("MATCH (a:person) RETURN a.isStudent").then(queryResult => { + queryResult.all({'callback': 5}).catch(err => { + assert.equal(err.message, "if execute is given a callback, it at most can take 2 arguments: (err, result)"); + done(); + }); + }) + }) + + it('should execute successfully with callback', function (done) { + conn.execute("MATCH (a:person) WHERE a.ID = 0 RETURN a.isStudent;").then((queryResult) => { + queryResult.all({ + 'callback': function (err, result) { + assert.equal(result[0]["a.isStudent"], true); + done(); + } + }); + }); + }) +}); \ No newline at end of file diff --git a/tools/nodejs_api/test/testGetHeader.js b/tools/nodejs_api/test/testGetHeader.js new file mode 100644 index 00000000000..40f008d34cf --- /dev/null +++ b/tools/nodejs_api/test/testGetHeader.js @@ -0,0 +1,27 @@ +const { assert } = require("chai"); + +describe("GETCOLUMNNAMES", function () { + it("should return columnName header names", function () { + conn.execute("MATCH (a:person)-[e:knows]->(b:person) RETURN a.fName, e.date, b.ID;") + .then((queryResult) => { + const columnNames = queryResult.getColumnNames() + assert.equal(columnNames[0] === 'a.fName', true) + assert.equal(columnNames[1] === 'e.date', true) + assert.equal(columnNames[2] === 'b.ID', true) + }); + }); +}); + +describe("GETCOLUMNDATATYPES", function () { + it("should return columnDataType header types", function () { + conn.execute("MATCH (p:person) RETURN p.ID, p.fName, p.isStudent, p.eyeSight, p.birthdate, p.registerTime, " + + "p.lastJobDuration, p.workedHours, p.courseScoresPerTerm;") + .then((queryResult) => { + const columnDataTypes = queryResult.getColumnDataTypes() + const ansArr = ['INT64', 'STRING', 'BOOL', 'DOUBLE', 'DATE', 'TIMESTAMP', 'INTERVAL', 'INT64[]', 'INT64[][]'] + ansArr.forEach(function (col, i) { + assert.equal(columnDataTypes[i] === col, true) + }); + }); + }); +}); \ No newline at end of file diff --git a/tools/nodejs_api/test/testParameter.js b/tools/nodejs_api/test/testParameter.js new file mode 100644 index 00000000000..9500aca7e59 --- /dev/null +++ b/tools/nodejs_api/test/testParameter.js @@ -0,0 +1,122 @@ +const { assert } = require("chai"); + +describe("TYPES", function () { + it("should return successfully for bool param to execute", function (done) { + conn.execute("MATCH (a:person) WHERE a.isStudent = $1 AND a.isWorker = $k RETURN COUNT(*)", + {'params': [["1", false], ["k", false]]}) + .then((queryResult) => { + queryResult.all().then(result => { + assert.equal(result[0]['COUNT_STAR()'], 1) + done(); + }) + }); + }); + + it("should return successfully for int param to execute", function (done) { + conn.execute("MATCH (a:person) WHERE a.age < $AGE RETURN COUNT(*)", + {'params': [["AGE", 1]]} ) + .then((queryResult) => { + queryResult.all().then(result => { + assert.equal(result[0]['COUNT_STAR()'], 0) + done(); + }) + }); + }); + + // TODO: When the param is 5.0 it will think it's a INT64 since it can be parsed into an INT, + // however 5.1, etc., correctly parses into a DOUBLE + // it("should return successfully for double param to execute", function (done) { + // conn.execute("MATCH (a:person) WHERE a.eyeSight = $E RETURN COUNT(*)", + // {'params': [["E", 5.0]]}) + // .then((queryResult) => { + // queryResult.all().then(result => { + // assert.equal(result[0]['COUNT_STAR()'], 2) + // done(); + // }) + // }).catch(err => { console.log(err); done();}); + // }); + + it("should return successfully for string param to execute", function (done) { + conn.execute("MATCH (a:person) WHERE a.ID = 0 RETURN concat(a.fName, $S);", + {'params': [["S", "HH"]]}) + .then((queryResult) => { + queryResult.all().then(result => { + assert.equal(result[0]['CONCAT(a.fName,$S)'], "AliceHH") + done(); + }) + }); + }); + + it("should return successfully for date param to execute", function (done) { + conn.execute("MATCH (a:person) WHERE a.birthdate = $1 RETURN COUNT(*);", + {'params': [["1", new Date(1900,0,1,-5)]]}) + .then((queryResult) => { + queryResult.all().then(result => { + assert.equal(result[0]['COUNT_STAR()'], 2) + done(); + }) + }); + }); + + it("should return successfully for timestamp param to execute", function (done) { + conn.execute("MATCH (a:person) WHERE a.registerTime = $1 RETURN COUNT(*);", + {'params': [["1", new Date(2011,7,20,7,25,30)]]}) + .then((queryResult) => { + queryResult.all().then(result => { + assert.equal(result[0]['COUNT_STAR()'], 1) + done(); + }) + }); + }); +}); + + +describe("EXCEPTION", function () { + it("should throw error when invalid name type is passed", function () { + return new Promise(async function (resolve) { + try { + await conn.execute("MATCH (a:person) WHERE a.registerTime = $1 RETURN COUNT(*);", + {'params': [[1, 1]]}) + } catch (err) { + assert.equal(err.message, "Unsuccessful execute: Parameter name must be of type string") + resolve(); + } + }) + }); + + it("should throw error when param not tuple", async function () { + return new Promise(async function (resolve) { + try { + await conn.execute("MATCH (a:person) WHERE a.registerTime = $1 RETURN COUNT(*);", + {'params': [["asd"]]}) + } catch (err) { + assert.equal(err.message, "Unsuccessful execute: Each parameter must be in the form of ") + resolve(); + } + }) + }); + + it("should throw error when param not tuple (long)", async function () { + return new Promise(async function (resolve) { + try { + await conn.execute("MATCH (a:person) WHERE a.registerTime = $1 RETURN COUNT(*);", + {'params': [["asd", 1, 1]]}) + } catch (err) { + assert.equal(err.message, "Unsuccessful execute: Each parameter must be in the form of ") + resolve(); + } + }) + }); + + it("should throw error when param value is invalid type", async function () { + return new Promise(async function (resolve) { + try { + await conn.execute("MATCH (a:person) WHERE a.registerTime = $1 RETURN COUNT(*);", + {'params': [["asd", {}]]}) + } catch (err) { + assert.equal(err.message, "Unsuccessful execute: Unknown parameter type") + resolve(); + } + }) + }); +});