Skip to content

Commit 5aadec2

Browse files
committed
Extending cpp bindings to support Clickhouse params thereby enabling parameterized query support
1 parent 469372c commit 5aadec2

File tree

4 files changed

+229
-3
lines changed

4 files changed

+229
-3
lines changed

index.d.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,16 @@
77
*/
88
export function query(query: string, format?: string): string;
99

10+
/**
11+
* Executes a query with parameters using the chdb addon.
12+
*
13+
* @param query The query string to execute.
14+
* @param binding arguments for parameters defined in the query.
15+
* @param format The format for the query result, default is "CSV".
16+
* @returns The query result as a string.
17+
*/
18+
export function queryBind(query:string, args: object, format?:string): string;
19+
1020
/**
1121
* Session class for managing queries and temporary paths.
1222
*/
@@ -37,6 +47,17 @@ export class Session {
3747
*/
3848
query(query: string, format?: string): string;
3949

50+
/**
51+
* Executes a query with parameters using the chdb addon.
52+
*
53+
* @param query The query string to execute.
54+
* @param binding arguments for parameters defined in the query.
55+
* @param format The format for the query result, default is "CSV".
56+
* @returns The query result as a string.
57+
*/
58+
59+
queryBind(query:string, args: object, format?: string): string;
60+
4061
/**
4162
* Cleans up the session, deleting the temporary directory if one was created.
4263
*/

index.js

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,13 @@ function query(query, format = "CSV") {
1212
return chdbNode.Query(query, format);
1313
}
1414

15+
function queryBind(query, args = {}, format = "CSV") {
16+
if(!query) {
17+
return "";
18+
}
19+
return chdbNode.QueryBindSession(query, args, format);
20+
}
21+
1522
// Session class with path handling
1623
class Session {
1724
constructor(path = "") {
@@ -30,10 +37,15 @@ class Session {
3037
return chdbNode.QuerySession(query, format, this.path);
3138
}
3239

40+
queryBind(query, args = {}, format = "CSV") {
41+
if(!query) return "";
42+
return chdbNode.QueryBindSession(query, args, format, this.path)
43+
}
44+
3345
// Cleanup method to delete the temporary directory
3446
cleanup() {
3547
rmSync(this.path, { recursive: true }); // Replaced rmdirSync with rmSync
3648
}
3749
}
3850

39-
module.exports = { query, Session };
51+
module.exports = { query, queryBind, Session };

lib/chdb_node.cpp

Lines changed: 150 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,76 @@
1010
#define MAX_PATH_LENGTH 4096
1111
#define MAX_ARG_COUNT 6
1212

13+
14+
static std::string toCHLiteral(const Napi::Env& env, const Napi::Value& v);
15+
16+
17+
static std::string chEscape(const std::string& s)
18+
{
19+
std::string out;
20+
out.reserve(s.size() + 4);
21+
out += '\'';
22+
for (char c : s) {
23+
if (c == '\'') out += "\\'";
24+
else out += c;
25+
}
26+
out += '\'';
27+
return out;
28+
}
29+
30+
static std::string toCHLiteral(const Napi::Env& env, const Napi::Value& v)
31+
{
32+
if (v.IsNumber() || v.IsBoolean() || v.IsString())
33+
return v.ToString().Utf8Value();
34+
35+
if (v.IsDate()) {
36+
double ms = v.As<Napi::Date>().ValueOf();
37+
std::time_t t = static_cast<std::time_t>(ms / 1000);
38+
std::tm tm{};
39+
gmtime_r(&t, &tm);
40+
char buf[32];
41+
strftime(buf, sizeof(buf), "%Y-%m-%d %H:%M:%S", &tm);
42+
return std::string(&buf[0], sizeof(buf));
43+
}
44+
45+
if (v.IsTypedArray()) {
46+
Napi::Object arr = env.Global().Get("Array").As<Napi::Object>();
47+
Napi::Function from = arr.Get("from").As<Napi::Function>();
48+
return toCHLiteral(env, from.Call(arr, { v }));
49+
}
50+
51+
if (v.IsArray()) {
52+
Napi::Array a = v.As<Napi::Array>();
53+
size_t n = a.Length();
54+
std::string out = "[";
55+
for (size_t i = 0; i < n; ++i) {
56+
if (i) out += ",";
57+
out += toCHLiteral(env, a.Get(i));
58+
}
59+
out += "]";
60+
return out;
61+
}
62+
63+
if (v.IsObject()) {
64+
Napi::Object o = v.As<Napi::Object>();
65+
Napi::Array keys = o.GetPropertyNames();
66+
size_t n = keys.Length();
67+
std::string out = "{";
68+
for (size_t i = 0; i < n; ++i) {
69+
if (i) out += ",";
70+
std::string k = keys.Get(i).ToString().Utf8Value();
71+
out += chEscape(k); // escape the map key with single-qoutes for click house query to work i.e 'key' not "key"
72+
out += ":";
73+
out += toCHLiteral(env, o.Get(keys.Get(i)));
74+
}
75+
out += "}";
76+
return out;
77+
}
78+
79+
/* Fallback – stringify & quote */
80+
return chEscape(v.ToString().Utf8Value());
81+
}
82+
1383
// Utility function to construct argument string
1484
void construct_arg(char *dest, const char *prefix, const char *value,
1585
size_t dest_size) {
@@ -92,6 +162,40 @@ char *QuerySession(const char *query, const char *format, const char *path,
92162
return result;
93163
}
94164

165+
char *QueryBindSession(const char *query, const char *format, const char *path,
166+
const std::vector<std::string>& params, char **error_message) {
167+
168+
std::vector<char*> argv;
169+
argv.reserve(4 + params.size() + (path[0] ? 1 : 0));
170+
171+
argv.push_back((char*)"clickhouse");
172+
argv.push_back((char*)"--multiquery");
173+
174+
std::string fmt = std::string("--output-format=") + format;
175+
argv.push_back(strdup(fmt.c_str()));
176+
177+
std::string q = std::string("--query=") + query;
178+
argv.push_back(strdup(q.c_str()));
179+
180+
for (auto& p : params)
181+
argv.push_back(strdup(p.c_str()));
182+
183+
if (path[0]) {
184+
std::string pth = std::string("--path=") + path;
185+
argv.push_back(strdup(pth.c_str()));
186+
}
187+
188+
#ifdef CHDB_DEBUG
189+
std::cerr << "=== chdb argv (" << argv.size() << ") ===\n";
190+
for (const char* arg : argv)
191+
std::cerr << arg << '\n';
192+
#endif
193+
194+
char* res = query_stable_v2((int)argv.size(), argv.data())->buf;
195+
// ...free strdup()’d strings afterwards
196+
return res;
197+
}
198+
95199
Napi::String QueryWrapper(const Napi::CallbackInfo &info) {
96200
Napi::Env env = info.Env();
97201

@@ -153,11 +257,56 @@ Napi::String QuerySessionWrapper(const Napi::CallbackInfo &info) {
153257
return Napi::String::New(env, result);
154258
}
155259

260+
static std::string jsToParam(const Napi::Env& env, const Napi::Value& v) {
261+
return toCHLiteral(env, v);
262+
}
263+
264+
Napi::String QueryBindSessionWrapper(const Napi::CallbackInfo& info) {
265+
Napi::Env env = info.Env();
266+
if (info.Length() < 2 || !info[0].IsString() || !info[1].IsObject())
267+
Napi::TypeError::New(env,"Usage: sql, params, [format]").ThrowAsJavaScriptException();
268+
269+
std::string sql = info[0].As<Napi::String>();
270+
Napi::Object obj = info[1].As<Napi::Object>();
271+
std::string format = (info.Length() > 2 && info[2].IsString())
272+
? info[2].As<Napi::String>() : std::string("CSV");
273+
std::string path = (info.Length() > 3 && info[3].IsString())
274+
? info[3].As<Napi::String>() : std::string("");
275+
276+
// Build param vector
277+
std::vector<std::string> cliParams;
278+
Napi::Array keys = obj.GetPropertyNames();
279+
int len = keys.Length();
280+
for (int i = 0; i < len; i++) {
281+
Napi::Value k = keys.Get(i);
282+
if(!k.IsString()) continue;
283+
284+
std::string key = k.As<Napi::String>();
285+
std::string val = jsToParam(env, obj.Get(k));
286+
cliParams.emplace_back("--param_" + key + "=" + val);
287+
}
288+
289+
#ifdef CHDB_DEBUG
290+
std::cerr << "=== cliParams ===\n";
291+
for (const auto& s : cliParams)
292+
std::cerr << s << '\n';
293+
#endif
294+
295+
char* err = nullptr;
296+
char* out = QueryBindSession(sql.c_str(), format.c_str(), path.c_str(), cliParams, &err);
297+
if (!out) {
298+
Napi::Error::New(env, err ? err : "unknown error").ThrowAsJavaScriptException();
299+
return Napi::String::New(env,"");
300+
}
301+
return Napi::String::New(env, out);
302+
}
303+
156304
Napi::Object Init(Napi::Env env, Napi::Object exports) {
157305
// Export the functions
158306
exports.Set("Query", Napi::Function::New(env, QueryWrapper));
159307
exports.Set("QuerySession", Napi::Function::New(env, QuerySessionWrapper));
308+
exports.Set("QueryBindSession", Napi::Function::New(env, QueryBindSessionWrapper));
160309
return exports;
161310
}
162311

163-
NODE_API_MODULE(NODE_GYP_MODULE_NAME, Init)
312+
NODE_API_MODULE(NODE_GYP_MODULE_NAME, Init)

test.js

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
const { expect } = require('chai');
2-
const { query, Session } = require(".");
2+
const { query, queryBind, Session } = require(".");
33

44
describe('chDB Queries', function () {
55

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

19+
it('should return version, greeting message, and chDB() using bind query', () => {
20+
const ret = queryBind("SELECT version(), 'Hello chDB', chDB()", {}, "CSV");
21+
console.log("Bind Query Result:", ret);
22+
expect(ret).to.be.a('string');
23+
expect(ret).to.include('Hello chDB');
24+
});
25+
26+
it('binds a numeric parameter (stand-alone query)', () => {
27+
const out = queryBind('SELECT {id:UInt32}', { id: 42 }, 'CSV').trim();
28+
console.log(out)
29+
expect(out).to.equal('42');
30+
});
31+
32+
it('binds a string parameter (stand-alone query)', () => {
33+
const out = queryBind(
34+
`SELECT concat('Hello ', {name:String})`,
35+
{ name: 'Alice' },
36+
'CSV'
37+
).trim();
38+
console.log(out)
39+
expect(out).to.equal('"Hello Alice"');
40+
});
41+
42+
it('binds Date and Map correctly', () => {
43+
const res = queryBind("SELECT {t: DateTime} AS t, {m: Map(String, Array(UInt8))} AS m",
44+
{
45+
t: new Date('2025-05-29T12:00:00Z'),
46+
m: { "abc": Uint8Array.from([1, 2, 3]) }
47+
},
48+
'JSONEachRow'
49+
);
50+
const row = JSON.parse(res.trim());
51+
expect(row.t).to.equal('2025-05-29 12:00:00');
52+
expect(row.m).to.deep.equal({ abc: [1, 2, 3] });
53+
});
54+
1955
describe('Session Queries', function () {
2056
let session;
2157

@@ -55,6 +91,14 @@ describe('chDB Queries', function () {
5591
session.query("SELECT * FROM non_existent_table;", "CSV");
5692
}).to.throw(Error, /Unknown table expression identifier/);
5793
});
94+
95+
it('should return result of the query made using bind parameters', () => {
96+
const ret = session.queryBind("SELECT * from testtable where id > {id: UInt32}", { id: 2}, "CSV");
97+
console.log("Bind Session result:", ret);
98+
expect(ret).to.not.include('1');
99+
expect(ret).to.not.include('2');
100+
expect(ret).to.include('3');
101+
})
58102
});
59103

60104
});

0 commit comments

Comments
 (0)