// Copyright 2026 Leo Cheng
// SPDX-License-Identifier: Apache-2.0
///|
/// A statement whose `:name` parameters have been rewritten to the backend's own
/// positional spelling, together with the name of the value each position takes.
///
/// The rewrite happens once, before the statement reaches the server: PostgreSQL's
/// extended-query protocol wants `$1`, `$2`, …, SQLite and MySQL want one `?` per
/// parameter. [`bind`](#bind) turns a name-keyed argument map into the ordered array
/// the driver expects, so a query layer writes
/// `SELECT * FROM hero WHERE id = :id AND realm = :realm` once and runs it on all
/// three backends.
///
/// The rewrite is a lexer, not a search-and-replace: a `:name` inside a string
/// literal, a quoted identifier, a `--` / `#` / `/* */` comment or a PostgreSQL
/// `$tag$ … $tag$` body is left alone, and neither `::` (a cast) nor `:=` (a named
/// function argument) opens a parameter.
///
/// ```moonbit check
/// test "named parameters become the backend's own spelling" {
/// let q = @moondb.rewrite_named(
/// "SELECT * FROM hero WHERE id = :id AND realm = :realm",
/// @moondb.Dialect::Postgres,
/// )
/// assert_eq(q.sql, "SELECT * FROM hero WHERE id = $1 AND realm = $2")
/// assert_eq(q.slots, ["id", "realm"])
///
/// let m = @moondb.rewrite_named(
/// "SELECT * FROM hero WHERE id = :id",
/// @moondb.Dialect::Mysql,
/// )
/// assert_eq(m.sql, "SELECT * FROM hero WHERE id = ?")
/// }
/// ```
pub(all) struct Named {
/// The SQL to send, with every `:name` replaced by the dialect's placeholder.
sql : String
/// The parameter name each positional slot takes, in order. A name repeated in
/// the statement repeats here — except on PostgreSQL, where a repeated name
/// reuses the same `$n` and so appears once.
slots : Array[String]
}
///|
/// Rewrite `:name` parameters in `sql` into `dialect`'s positional spelling.
///
/// A statement may use either `:name` or positional `?`, never both; mixing them
/// raises a `QueryError` naming the offset, because the two conventions disagree
/// about what the caller's array means.
pub fn rewrite_named(sql : String, dialect : Dialect) -> Named raise DbError {
scan_named(sql, dialect)
}
///|
/// The distinct parameter names, in first-use order.
pub fn Named::names(self : Named) -> Array[String] {
let out : Array[String] = []
for name in self.slots {
if !out.contains(name) {
out.push(name)
}
}
out
}
///|
/// How many values the statement binds.
pub fn Named::arity(self : Named) -> Int32 {
self.slots.length()
}
///|
/// Whether the statement takes named parameters at all.
pub fn Named::is_named(self : Named) -> Bool {
!self.slots.is_empty()
}
///|
/// Turn a name-keyed argument map into the positional array the statement binds.
/// A missing name raises a `TypeError` naming the parameter; a key the statement
/// never mentions raises one too, so a typo in a repository method is caught at the
/// call rather than silently ignored.
pub fn Named::bind(
self : Named,
args : Map[String, Value],
) -> Array[Value] raise DbError {
let out : Array[Value] = []
for name in self.slots {
match args.get(name) {
Some(v) => out.push(v)
None => raise TypeError("missing parameter :" + name)
}
}
for name in args.keys() {
if !self.slots.contains(name) {
raise TypeError("unexpected parameter :" + name)
}
}
out
}
///|
/// The lexer behind [`rewrite_named`]. One pass over the text, copying quoted spans
/// and comments through verbatim and numbering only the `:` that opens a name.
fn scan_named(sql : String, dialect : Dialect) -> Named raise DbError {
let sb = StringBuilder()
let slots : Array[String] = []
// PostgreSQL reuses one number for a repeated name; SQLite and MySQL append one
// value per occurrence, so the same name can occupy several slots.
let numbers : Map[String, Int32] = Map([])
let n = sql.length()
let mut i = 0
let mut saw_question = false
while i < n {
let c = sql[i]
if c == '\'' {
i = copy_quoted(sb, sql, i, dialect)
} else if c == '"' {
i = copy_delimited(sb, sql, i, '"')
} else if c == '`' && dialect == Mysql {
i = copy_delimited(sb, sql, i, '`')
} else if c == '$' && (dialect == Postgres || dialect == Mysql) {
// PostgreSQL dollar-quoted body, or MySQL's `$` inside an identifier (which
// cannot open a parameter anyway).
let end = dollar_quote_end(sql, i)
if end < 0 {
sb.write_char('$')
i += 1
} else {
copy_span(sb, sql, i, end)
i = end
}
} else if c == '-' && i + 1 < n && sql[i + 1] == '-' {
i = copy_line_comment(sb, sql, i)
} else if c == '#' && dialect == Mysql {
i = copy_line_comment(sb, sql, i)
} else if c == '/' && i + 1 < n && sql[i + 1] == '*' {
i = copy_block_comment(sb, sql, i, dialect == Postgres)
} else if c == '?' && dialect != Postgres {
if !slots.is_empty() {
raise QueryError(
"statement mixes ? and :name parameters at offset " + i.to_string(),
)
}
saw_question = true
sb.write_char('?')
i += 1
} else if c == ':' && i + 1 < n && sql[i + 1] == ':' {
sb.write_char(':')
sb.write_char(':')
i += 2
} else if c == ':' && i + 1 < n && sql[i + 1] == '=' {
sb.write_char(':')
sb.write_char('=')
i += 2
} else if c == ':' && i + 1 < n && is_name_start(sql[i + 1]) {
if saw_question {
raise QueryError(
"statement mixes ? and :name parameters at offset " + i.to_string(),
)
}
let start = i + 1
let mut end = start
while end < n && is_name_part(sql[end]) {
end += 1
}
let name = sql[start:end].to_owned()
match dialect {
Postgres =>
if numbers.get(name) is Some(k) {
push_str(sb, Dialect::Postgres.placeholder(k))
} else {
let k = slots.length() + 1
numbers.set(name, k)
slots.push(name)
push_str(sb, Dialect::Postgres.placeholder(k))
}
Sqlite | Mysql => {
slots.push(name)
sb.write_char('?')
}
}
i = end
} else {
sb.write_char(c.unsafe_to_char())
i += 1
}
}
{ sql: sb.to_string(), slots, }
}
///|
fn is_name_start(c : UInt16) -> Bool {
let ci = c.to_int()
(ci >= 65 && ci <= 90) || (ci >= 97 && ci <= 122) || ci == 95
}
///|
fn is_name_part(c : UInt16) -> Bool {
is_name_start(c) ||
({
let ci = c.to_int()
ci >= 48 && ci <= 57
})
}
///|
/// Copy a span verbatim and return the index just past it.
fn copy_span(
sb : StringBuilder,
sql : String,
from : Int32,
to : Int32,
) -> Unit {
for i in from.. Int32 {
let n = sql.length()
let mut i = start
sb.write_char(quote.unsafe_to_char())
i += 1
while i < n {
let d = sql[i]
sb.write_char(d.unsafe_to_char())
if d == quote {
if i + 1 < n && sql[i + 1] == quote {
sb.write_char(quote.unsafe_to_char())
i += 2
continue
}
return i + 1
}
i += 1
}
i
}
///|
/// Copy a single-quoted string literal and return the index just past it.
///
/// Backslash escapes are a MySQL extension; in SQLite and in standard-conforming
/// PostgreSQL a backslash is an ordinary character, so honouring it there would run
/// past the real closing quote and swallow the rest of the statement. PostgreSQL's
/// `E'…'` form is the exception and is honoured.
fn copy_quoted(
sb : StringBuilder,
sql : String,
start : Int32,
dialect : Dialect,
) -> Int32 {
let n = sql.length()
let escapes = dialect == Mysql ||
(dialect == Postgres && start > 0 && is_escape_prefix(sql, start))
let mut i = start
sb.write_char('\'')
i += 1
while i < n {
let d = sql[i]
sb.write_char(d.unsafe_to_char())
if escapes && d == '\\' && i + 1 < n {
sb.write_char(sql[i + 1].unsafe_to_char())
i += 2
continue
}
if d == '\'' {
if i + 1 < n && sql[i + 1] == '\'' {
sb.write_char('\'')
i += 2
continue
}
return i + 1
}
i += 1
}
i
}
///|
/// Whether the `'` at `start` is the opening quote of a PostgreSQL `E'…'` string.
fn is_escape_prefix(sql : String, start : Int32) -> Bool {
if start == 0 {
return false
}
let p = sql[start - 1]
if p != 'E' && p != 'e' {
return false
}
start < 2 || !is_name_part(sql[start - 2])
}
///|
/// Copy a `--` or `#` comment through its newline and return the index just past
/// it (the newline itself is copied).
fn copy_line_comment(sb : StringBuilder, sql : String, start : Int32) -> Int32 {
let n = sql.length()
let mut i = start
while i < n && sql[i] != '\n' {
sb.write_char(sql[i].unsafe_to_char())
i += 1
}
if i < n {
sb.write_char('\n')
i += 1
}
i
}
///|
/// Copy a block comment and return the index just past it. PostgreSQL nests them;
/// SQLite and MySQL end at the first `*/`, so treating a nested opener as nested
/// there would swallow the code between them.
fn copy_block_comment(
sb : StringBuilder,
sql : String,
start : Int32,
nested : Bool,
) -> Int32 {
let n = sql.length()
let mut i = start
sb.write_char('/')
sb.write_char('*')
i += 2
let mut depth = 1
while i < n && depth > 0 {
if i + 1 < n && sql[i] == '/' && sql[i + 1] == '*' && nested {
depth += 1
sb.write_char('/')
sb.write_char('*')
i += 2
} else if i + 1 < n && sql[i] == '*' && sql[i + 1] == '/' {
depth -= 1
sb.write_char('*')
sb.write_char('/')
i += 2
} else {
sb.write_char(sql[i].unsafe_to_char())
i += 1
}
}
i
}
///|
/// The index just past a PostgreSQL dollar-quoted string starting at `start`, or
/// `-1` when `start` does not open one (`$1` is a parameter, not a tag).
fn dollar_quote_end(sql : String, start : Int32) -> Int32 {
let n = sql.length()
// $tag$ — the tag may be empty, and `$1` (digits) is not a tag.
let mut i = start + 1
if i < n && sql[i] == '$' {
// empty tag: $$
return copy_dollar_body(sql, start, i + 1)
}
if i >= n || !is_name_start(sql[i]) {
return -1
}
while i < n {
let c = sql[i]
if c == '$' {
return copy_dollar_body(sql, start, i + 1)
}
if !is_name_part(c) {
return -1
}
i += 1
}
-1
}
///|
/// Find the closing tag of the dollar-quoted body whose tag ends at `body_start`,
/// returning the index just past it, or the end of the text when it never closes.
fn copy_dollar_body(sql : String, start : Int32, body_start : Int32) -> Int32 {
let tag = sql[start:body_start].to_owned()
let n = sql.length()
let close = find_sub(tag, sql, body_start)
if close < 0 {
n
} else {
close + tag.length()
}
}
///|
/// First index at or after `from` where `needle` occurs in `hay`, or `-1`.
fn find_sub(needle : String, hay : String, from : Int32) -> Int32 {
let hn = hay.length()
let nn = needle.length()
if nn == 0 {
return from
}
let mut i = from
while i + nn <= hn {
let mut j = 0
while j < nn && hay[i + j] == needle[j] {
j += 1
}
if j == nn {
return i
}
i += 1
}
-1
}
///|
fn push_str(sb : StringBuilder, s : String) -> Unit {
for i in 0..