Skip to content

Extending cpp bindings to support Clickhouse params thereby enabling … #24

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Jun 3, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,16 @@
*/
export function query(query: string, format?: string): string;

/**
* Executes a query with parameters using the chdb addon.
*
* @param query The query string to execute.
* @param binding arguments for parameters defined in the query.
* @param format The format for the query result, default is "CSV".
* @returns The query result as a string.
*/
export function queryBind(query:string, args: object, format?:string): string;

/**
* Session class for managing queries and temporary paths.
*/
Expand Down Expand Up @@ -37,6 +47,17 @@ export class Session {
*/
query(query: string, format?: string): string;

/**
* Executes a query with parameters using the chdb addon.
*
* @param query The query string to execute.
* @param binding arguments for parameters defined in the query.
* @param format The format for the query result, default is "CSV".
* @returns The query result as a string.
*/

queryBind(query:string, args: object, format?: string): string;

/**
* Cleans up the session, deleting the temporary directory if one was created.
*/
Expand Down
14 changes: 13 additions & 1 deletion index.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,13 @@ function query(query, format = "CSV") {
return chdbNode.Query(query, format);
}

function queryBind(query, args = {}, format = "CSV") {
if(!query) {
return "";
}
return chdbNode.QueryBindSession(query, args, format);
}

// Session class with path handling
class Session {
constructor(path = "") {
Expand All @@ -30,10 +37,15 @@ class Session {
return chdbNode.QuerySession(query, format, this.path);
}

queryBind(query, args = {}, format = "CSV") {
if(!query) return "";
return chdbNode.QueryBindSession(query, args, format, this.path)
}

// Cleanup method to delete the temporary directory
cleanup() {
rmSync(this.path, { recursive: true }); // Replaced rmdirSync with rmSync
}
}

module.exports = { query, Session };
module.exports = { query, queryBind, Session };
149 changes: 144 additions & 5 deletions lib/chdb_node.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,76 @@
#define MAX_PATH_LENGTH 4096
#define MAX_ARG_COUNT 6


static std::string toCHLiteral(const Napi::Env& env, const Napi::Value& v);


static std::string chEscape(const std::string& s)
{
std::string out;
out.reserve(s.size() + 4);
out += '\'';
for (char c : s) {
if (c == '\'') out += "\\'";
else out += c;
}
out += '\'';
return out;
}

static std::string toCHLiteral(const Napi::Env& env, const Napi::Value& v)
{
if (v.IsNumber() || v.IsBoolean() || v.IsString())
return v.ToString().Utf8Value();

if (v.IsDate()) {
double ms = v.As<Napi::Date>().ValueOf();
std::time_t t = static_cast<std::time_t>(ms / 1000);
std::tm tm{};
gmtime_r(&t, &tm);
char buf[32];
strftime(buf, sizeof(buf), "%Y-%m-%d %H:%M:%S", &tm);
return std::string(&buf[0], sizeof(buf));
}

if (v.IsTypedArray()) {
Napi::Object arr = env.Global().Get("Array").As<Napi::Object>();
Napi::Function from = arr.Get("from").As<Napi::Function>();
return toCHLiteral(env, from.Call(arr, { v }));
}

if (v.IsArray()) {
Napi::Array a = v.As<Napi::Array>();
size_t n = a.Length();
std::string out = "[";
for (size_t i = 0; i < n; ++i) {
if (i) out += ",";
out += toCHLiteral(env, a.Get(i));
}
out += "]";
return out;
}

if (v.IsObject()) {
Napi::Object o = v.As<Napi::Object>();
Napi::Array keys = o.GetPropertyNames();
size_t n = keys.Length();
std::string out = "{";
for (size_t i = 0; i < n; ++i) {
if (i) out += ",";
std::string k = keys.Get(i).ToString().Utf8Value();
out += chEscape(k); // escape the map key with single-qoutes for click house query to work i.e 'key' not "key"
out += ":";
out += toCHLiteral(env, o.Get(keys.Get(i)));
}
out += "}";
return out;
}

/* Fallback – stringify & quote */
return chEscape(v.ToString().Utf8Value());
}

// Utility function to construct argument string
void construct_arg(char *dest, const char *prefix, const char *value,
size_t dest_size) {
Expand Down Expand Up @@ -92,21 +162,46 @@ char *QuerySession(const char *query, const char *format, const char *path,
return result;
}

char *QueryBindSession(const char *query, const char *format, const char *path,
const std::vector<std::string>& params, char **error_message) {

std::vector<std::string> store;
store.reserve(4 + params.size() + (path && path[0] ? 1 : 0));

store.emplace_back("clickhouse");
store.emplace_back("--multiquery");
store.emplace_back(std::string("--output-format=") + format);
store.emplace_back(std::string("--query=") + query);

for (const auto& p : params) store.emplace_back(p);
if (path && path[0]) store.emplace_back(std::string("--path=") + path);

std::vector<char*> argv;
argv.reserve(store.size());
for (auto& s : store)
argv.push_back(const_cast<char*>(s.c_str()));

#ifdef CHDB_DEBUG
std::cerr << "=== chdb argv (" << argv.size() << ") ===\n";
for (char* a : argv) std::cerr << a << '\n';
#endif

return query_stable_v2(static_cast<int>(argv.size()), argv.data())->buf;
}

Napi::String QueryWrapper(const Napi::CallbackInfo &info) {
Napi::Env env = info.Env();

// Check argument types and count
if (info.Length() < 2 || !info[0].IsString() || !info[1].IsString()) {
Napi::TypeError::New(env, "String expected").ThrowAsJavaScriptException();
return Napi::String::New(env, "");
}

// Get the arguments
std::string query = info[0].As<Napi::String>().Utf8Value();
std::string format = info[1].As<Napi::String>().Utf8Value();

char *error_message = nullptr;
// Call the native function

char *result = Query(query.c_str(), format.c_str(), &error_message);

if (result == NULL) {
Expand All @@ -117,7 +212,6 @@ Napi::String QueryWrapper(const Napi::CallbackInfo &info) {
return Napi::String::New(env, "");
}

// Return the result
return Napi::String::New(env, result);
}

Expand Down Expand Up @@ -153,11 +247,56 @@ Napi::String QuerySessionWrapper(const Napi::CallbackInfo &info) {
return Napi::String::New(env, result);
}

static std::string jsToParam(const Napi::Env& env, const Napi::Value& v) {
return toCHLiteral(env, v);
}

Napi::String QueryBindSessionWrapper(const Napi::CallbackInfo& info) {
Napi::Env env = info.Env();
if (info.Length() < 2 || !info[0].IsString() || !info[1].IsObject())
Napi::TypeError::New(env,"Usage: sql, params, [format]").ThrowAsJavaScriptException();

std::string sql = info[0].As<Napi::String>();
Napi::Object obj = info[1].As<Napi::Object>();
std::string format = (info.Length() > 2 && info[2].IsString())
? info[2].As<Napi::String>() : std::string("CSV");
std::string path = (info.Length() > 3 && info[3].IsString())
? info[3].As<Napi::String>() : std::string("");

// Build param vector
std::vector<std::string> cliParams;
Napi::Array keys = obj.GetPropertyNames();
int len = keys.Length();
for (int i = 0; i < len; i++) {
Napi::Value k = keys.Get(i);
if(!k.IsString()) continue;

std::string key = k.As<Napi::String>();
std::string val = jsToParam(env, obj.Get(k));
cliParams.emplace_back("--param_" + key + "=" + val);
}

#ifdef CHDB_DEBUG
std::cerr << "=== cliParams ===\n";
for (const auto& s : cliParams)
std::cerr << s << '\n';
#endif

char* err = nullptr;
char* out = QueryBindSession(sql.c_str(), format.c_str(), path.c_str(), cliParams, &err);
if (!out) {
Napi::Error::New(env, err ? err : "unknown error").ThrowAsJavaScriptException();
return Napi::String::New(env,"");
}
return Napi::String::New(env, out);
}

Napi::Object Init(Napi::Env env, Napi::Object exports) {
// Export the functions
exports.Set("Query", Napi::Function::New(env, QueryWrapper));
exports.Set("QuerySession", Napi::Function::New(env, QuerySessionWrapper));
exports.Set("QueryBindSession", Napi::Function::New(env, QueryBindSessionWrapper));
return exports;
}

NODE_API_MODULE(NODE_GYP_MODULE_NAME, Init)
NODE_API_MODULE(NODE_GYP_MODULE_NAME, Init)
46 changes: 45 additions & 1 deletion test.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
const { expect } = require('chai');
const { query, Session } = require(".");
const { query, queryBind, Session } = require(".");

describe('chDB Queries', function () {

Expand All @@ -16,6 +16,42 @@ describe('chDB Queries', function () {
}).to.throw(Error, /Unknown table expression identifier/);
});

it('should return version, greeting message, and chDB() using bind query', () => {
const ret = queryBind("SELECT version(), 'Hello chDB', chDB()", {}, "CSV");
console.log("Bind Query Result:", ret);
expect(ret).to.be.a('string');
expect(ret).to.include('Hello chDB');
});

it('binds a numeric parameter (stand-alone query)', () => {
const out = queryBind('SELECT {id:UInt32}', { id: 42 }, 'CSV').trim();
console.log(out)
expect(out).to.equal('42');
});

it('binds a string parameter (stand-alone query)', () => {
const out = queryBind(
`SELECT concat('Hello ', {name:String})`,
{ name: 'Alice' },
'CSV'
).trim();
console.log(out)
expect(out).to.equal('"Hello Alice"');
});

it('binds Date and Map correctly', () => {
const res = queryBind("SELECT {t: DateTime} AS t, {m: Map(String, Array(UInt8))} AS m",
{
t: new Date('2025-05-29T12:00:00Z'),
m: { "abc": Uint8Array.from([1, 2, 3]) }
},
'JSONEachRow'
);
const row = JSON.parse(res.trim());
expect(row.t).to.equal('2025-05-29 12:00:00');
expect(row.m).to.deep.equal({ abc: [1, 2, 3] });
});

describe('Session Queries', function () {
let session;

Expand Down Expand Up @@ -55,6 +91,14 @@ describe('chDB Queries', function () {
session.query("SELECT * FROM non_existent_table;", "CSV");
}).to.throw(Error, /Unknown table expression identifier/);
});

it('should return result of the query made using bind parameters', () => {
const ret = session.queryBind("SELECT * from testtable where id > {id: UInt32}", { id: 2}, "CSV");
console.log("Bind Session result:", ret);
expect(ret).to.not.include('1');
expect(ret).to.not.include('2');
expect(ret).to.include('3');
})
});

});
Expand Down