// Copyright 2026 Leo Cheng
// SPDX-License-Identifier: Apache-2.0
// Positional binding on a server whose parameters are named and typed. The driver
// rewrites each `?` into ClickHouse's `{pN:Type}` syntax, choosing the type from the
// `Value` the caller bound, and passes the values beside the query as URL parameters —
// the server substitutes them, so nothing is ever spliced into the SQL text.
///|
/// A statement ready to send: the rewritten text and the name/value pairs.
priv struct Bound {
sql : String
params : Array[(String, Bytes)]
}
///|
/// Rewrite every `?` outside a string literal, a quoted identifier or a comment into
/// `{pN:Type}`, and encode the matching value.
fn bind(
sql : String,
values : Array[@moondb.Value],
) -> Bound raise @moondb.DbError {
let out = Buffer()
let params : Array[(String, Bytes)] = []
let n = sql.length()
let mut i = 0
let mut start = 0
let mut slot = 0
while i < n {
let c = sql[i]
if c == '\'' || c == '"' || c == '`' {
// A literal or a quoted identifier: skip it, doubling the quote is the only
// escape ClickHouse has.
i += 1
while i < n {
if sql[i] == c {
if i + 1 < n && sql[i + 1] == c {
i += 2
continue
}
i += 1
break
}
i += 1
}
continue
}
if c == '-' && i + 1 < n && sql[i + 1] == '-' {
while i < n && sql[i] != '\n' {
i += 1
}
continue
}
if c == '/' && i + 1 < n && sql[i + 1] == '*' {
i += 2
while i < n {
if sql[i] == '*' && i + 1 < n && sql[i + 1] == '/' {
i += 2
break
}
i += 1
}
continue
}
if c == '?' {
if slot >= values.length() {
raise @moondb.TypeError(
"statement has " +
(slot + 1).to_string() +
" placeholders but " +
values.length().to_string() +
" values",
)
}
put(out, sql[start:i])
let name = "p" + (slot + 1).to_string()
let (type_, text) = encode(values[slot], slot)
put(out, "{" + name + ":" + type_ + "}")
params.push((name, text))
slot += 1
i += 1
start = i
continue
}
i += 1
}
put(out, sql[start:])
if slot != values.length() {
raise @moondb.TypeError(
"statement has " +
slot.to_string() +
" placeholders but " +
values.length().to_string() +
" values",
)
}
let text = @utf8.decode(out.to_bytes()) catch {
_ => raise @moondb.QueryError("statement is not valid UTF-8")
}
{ sql: text, params, }
}
///|
/// The ClickHouse type and the text a `Value` travels as. The type is what the server
/// parses the text into, so it has to match the bound case exactly; a case the HTTP
/// interface cannot carry raises rather than being coerced into something that would
/// read back differently.
fn encode(
value : @moondb.Value,
slot : Int32,
) -> (String, Bytes) raise @moondb.DbError {
let as_text = fn(text : String) { ("String", @utf8.encode(text)) }
match value {
Null => ("Nullable(String)", b"\\N")
Bool(b) => ("Bool", if b { b"1" } else { b"0" })
Int(i) => ("Int32", @utf8.encode(i.to_string()))
Int64(i) => ("Int64", @utf8.encode(i.to_string()))
Double(d) => ("Float64", @utf8.encode(d.to_string()))
Text(s) => as_text(s)
Time(s) => as_text(s)
Numeric(s) => as_text(s)
Json(s) => as_text(s)
// A UUID travels as its canonical text, which ClickHouse's `UUID` accepts and which
// keeps the parameter a string on the wire like every other value.
Uuid(bytes) => ("String", @utf8.encode(uuid_text(bytes)))
// A `String` column is a byte string on this server, and a URL parameter is
// percent-encoded bytes, so binary values travel exactly as they are.
Blob(bytes) => ("String", bytes)
Array(_) =>
raise @moondb.TypeError(
"parameter " +
(slot + 1).to_string() +
" is an array: ClickHouse's HTTP parameters are scalars, so expand it into placeholders",
)
}
}
///|
/// The canonical `8-4-4-4-12` text of a UUID's 16 bytes.
fn uuid_text(bytes : Bytes) -> String {
if bytes.length() != 16 {
return ""
}
let digits = "0123456789abcdef"
let buf = Buffer()
for i = 0; i < 16; i = i + 1 {
if i == 4 || i == 6 || i == 8 || i == 10 {
put(buf, "-")
}
let high = (bytes[i].to_int() >> 4) & 0xF
let low = bytes[i].to_int() & 0xF
put(buf, digits[high:high + 1])
put(buf, digits[low:low + 1])
}
@utf8.decode(buf.to_bytes()) catch {
_ => ""
}
}
///|
/// Percent-encode a value for a URL query string: everything outside the unreserved
/// set becomes `%XX`, which is what ClickHouse's parameter parser expects.
fn url_encode(raw : Bytes) -> String {
let digits = "0123456789ABCDEF"
let buf = Buffer()
for byte in raw {
let b = byte.to_int()
let unreserved = (b >= 0x41 && b <= 0x5A) ||
(b >= 0x61 && b <= 0x7A) ||
(b >= 0x30 && b <= 0x39) ||
b == 0x2D ||
b == 0x2E ||
b == 0x5F ||
b == 0x7E
if unreserved {
buf.write_byte(byte)
} else {
put(buf, "%")
put(buf, digits[(b >> 4) & 0xF:((b >> 4) & 0xF) + 1])
put(buf, digits[b & 0xF:(b & 0xF) + 1])
}
}
@utf8.decode(buf.to_bytes()) catch {
_ => ""
}
}