// Copyright 2026 Leo Cheng
// SPDX-License-Identifier: Apache-2.0
// Type-value round-tripping in the wire's *text* format. This round binds and
// decodes everything as text (result format code 0); binary format is a later
// round (README roadmap). PostgreSQL type OIDs are stable, well-known integers.
///|
/// The `pg_type` OID of `bool`, as a column description reports it.
pub const OID_BOOL : Int32 = 16
///|
/// The `pg_type` OID of `bytea`, as a column description reports it.
pub const OID_BYTEA : Int32 = 17
///|
/// The `pg_type` OID of `int8`, as a column description reports it.
pub const OID_INT8 : Int32 = 20
///|
/// The `pg_type` OID of `int2`, as a column description reports it.
pub const OID_INT2 : Int32 = 21
///|
/// The `pg_type` OID of `int4`, as a column description reports it.
pub const OID_INT4 : Int32 = 23
///|
/// The `pg_type` OID of `float4`, as a column description reports it.
pub const OID_FLOAT4 : Int32 = 700
///|
/// The `pg_type` OID of `float8`, as a column description reports it.
pub const OID_FLOAT8 : Int32 = 701
///|
/// The `pg_type` OID of `json`.
pub const OID_JSON : Int32 = 114
///|
/// The `pg_type` OID of `jsonb`.
pub const OID_JSONB : Int32 = 3802
///|
/// The `pg_type` OID of `date`.
pub const OID_DATE : Int32 = 1082
///|
/// The `pg_type` OID of `time`.
pub const OID_TIME : Int32 = 1083
///|
/// The `pg_type` OID of `timetz`.
pub const OID_TIMETZ : Int32 = 1266
///|
/// The `pg_type` OID of `timestamp`.
pub const OID_TIMESTAMP : Int32 = 1114
///|
/// The `pg_type` OID of `timestamptz`.
pub const OID_TIMESTAMPTZ : Int32 = 1184
///|
/// The `pg_type` OID of `numeric`.
pub const OID_NUMERIC : Int32 = 1700
///|
/// The `pg_type` OID of `uuid`.
pub const OID_UUID : Int32 = 2950
///|
/// Encode a bound parameter to its text-format bytes, or `None` for SQL `NULL`
/// (which the Bind message sends as a length of `-1`). Binding is out-of-band —
/// the value never touches the SQL string — so this is the injection-safe path.
///
/// * integers / doubles / bools render to their canonical PostgreSQL text
/// literals (`t`/`f` for booleans);
/// * `Text` passes through UTF-8;
/// * `Blob` uses the `bytea` hex format (`\x` + lowercase hex), which the server
/// accepts for a text-format `bytea` parameter.
pub fn encode_param(v : Value) -> Bytes? {
match v {
@moondb.Null => None
@moondb.Bool(b) => Some(@utf8.encode(if b { "t" } else { "f" }))
@moondb.Int(n) => Some(@utf8.encode(n.to_string()))
@moondb.Int64(n) => Some(@utf8.encode(n.to_string()))
@moondb.Double(d) => Some(@utf8.encode(d.to_string()))
@moondb.Text(s) => Some(@utf8.encode(s))
@moondb.Blob(b) =>
Some(@utf8.encode("\\x" + @base16.encode(b[:]).to_lower()))
@moondb.Time(s) => Some(@utf8.encode(s))
@moondb.Numeric(s) => Some(@utf8.encode(s))
@moondb.Uuid(b) => Some(@utf8.encode(uuid_text(b)))
@moondb.Json(s) => Some(@utf8.encode(s))
@moondb.Array(items) => Some(@utf8.encode(array_literal(items)))
}
}
///|
/// Decode a text-format result cell (`raw`, already UTF-8) tagged with its
/// column `oid` into a [`Value`]. `is_null` marks a wire `NULL` (length `-1`),
/// which decodes to `Null` regardless of type. Numeric and boolean OIDs decode
/// to their typed cases; everything else — including `numeric`, dates, and
/// unknown OIDs — rides back as `Text`, exactly the dialect-neutral contract
/// moondb documents (temporal/`numeric` typing is a roadmap item).
pub fn decode_value(oid : Int32, is_null : Bool, raw : String) -> Value {
if is_null {
return @moondb.Null
}
if oid == OID_BOOL {
@moondb.Bool(raw == "t")
} else if oid == OID_INT2 || oid == OID_INT4 {
match parse_int(raw) {
Some(n) => @moondb.Int(n)
None => @moondb.Text(raw)
}
} else if oid == OID_INT8 {
match parse_int64(raw) {
Some(n) => @moondb.Int64(n)
None => @moondb.Text(raw)
}
} else if oid == OID_FLOAT4 || oid == OID_FLOAT8 {
match parse_double(raw) {
Some(d) => @moondb.Double(d)
None => @moondb.Text(raw)
}
} else if oid == OID_BYTEA {
@moondb.Blob(decode_bytea(raw))
} else if oid == OID_NUMERIC {
@moondb.Numeric(raw)
} else if oid == OID_UUID {
@moondb.Uuid(decode_uuid(raw))
} else if oid == OID_JSON || oid == OID_JSONB {
@moondb.Json(raw)
} else if oid == OID_DATE ||
oid == OID_TIME ||
oid == OID_TIMETZ ||
oid == OID_TIMESTAMP ||
oid == OID_TIMESTAMPTZ {
@moondb.Time(normalize_timestamp(raw))
} else {
match array_element_oid(oid) {
Some(elem) => @moondb.Array(parse_array_text(raw, elem))
None => @moondb.Text(raw)
}
}
}
///|
/// Format a 16-byte UUID as the canonical `8-4-4-4-12` hex text PostgreSQL
/// accepts for a `uuid` parameter or array element.
fn uuid_text(b : Bytes) -> String {
let hex = @base16.encode(b[:]).to_lower()
let sb = StringBuilder()
for i in 0.. Bytes {
let sb = StringBuilder()
for i in 0.. String {
let sb = StringBuilder()
let mut done = false
for i in 0.. Int32? {
if oid == 1000 {
Some(OID_BOOL)
} else if oid == 1005 {
Some(OID_INT2)
} else if oid == 1007 {
Some(OID_INT4)
} else if oid == 1016 {
Some(OID_INT8)
} else if oid == 1021 {
Some(OID_FLOAT4)
} else if oid == 1022 {
Some(OID_FLOAT8)
} else if oid == 1009 {
Some(25)
} else if oid == 1015 {
Some(1043)
} else if oid == 1231 {
Some(OID_NUMERIC)
} else if oid == 2951 {
Some(OID_UUID)
} else if oid == 3807 {
Some(OID_JSONB)
} else if oid == 1115 {
Some(OID_TIMESTAMP)
} else if oid == 1185 {
Some(OID_TIMESTAMPTZ)
} else if oid == 1182 {
Some(OID_DATE)
} else {
None
}
}
///|
/// A PostgreSQL array literal (`{...}`) for an `Array` value bound as a parameter.
fn array_literal(items : Array[Value]) -> String {
let sb = StringBuilder()
sb.write_char('{')
for i in 0.. 0 {
sb.write_char(',')
}
match items[i] {
@moondb.Null => sb.write_string("NULL")
@moondb.Array(inner) => sb.write_string(array_literal(inner))
@moondb.Text(s) => write_array_element(sb, s)
@moondb.Time(s) => write_array_element(sb, s)
@moondb.Numeric(s) => sb.write_string(s)
@moondb.Json(s) => write_array_element(sb, s)
@moondb.Uuid(b) => write_array_element(sb, uuid_text(b))
@moondb.Bool(v) => sb.write_string(if v { "t" } else { "f" })
@moondb.Int(n) => sb.write_string(n.to_string())
@moondb.Int64(n) => sb.write_string(n.to_string())
@moondb.Double(d) => sb.write_string(d.to_string())
@moondb.Blob(b) =>
write_array_element(sb, "\\x" + @base16.encode(b[:]).to_lower())
}
}
sb.write_char('}')
sb.to_string()
}
///|
fn write_array_element(sb : StringBuilder, s : String) -> Unit {
sb.write_char('"')
for i in 0.. Array[Value] {
let items : Array[Value] = []
let n = raw.length()
let mut i = 0
if i < n && raw[i].to_int() == 123 {
i += 1
}
while i < n {
while i < n && is_ws_char(raw[i]) {
i += 1
}
if i >= n {
break
}
let c = raw[i].to_int()
if c == 125 {
break
}
if c == 44 {
i += 1
continue
}
if c == 123 {
let (sub, next) = scan_nested_array(raw, i)
items.push(@moondb.Array(parse_array_text(sub, elem_oid)))
i = next
} else if c == 34 {
let (s, next) = scan_quoted_array_element(raw, i)
items.push(decode_value(elem_oid, false, s))
i = next
} else {
let (token, next) = scan_unquoted_array_element(raw, i)
if token == "NULL" {
items.push(@moondb.Null)
} else {
items.push(decode_value(elem_oid, false, token))
}
i = next
}
}
items
}
///|
fn is_ws_char(c : UInt16) -> Bool {
let ci = c.to_int()
ci == 32 || ci == 9 || ci == 10 || ci == 13
}
///|
fn scan_quoted_array_element(raw : String, start : Int32) -> (String, Int32) {
let sb = StringBuilder()
let n = raw.length()
let mut i = start + 1
while i < n {
let c = raw[i]
if c.to_int() == 92 && i + 1 < n {
i += 1
sb.write_char(raw[i].unsafe_to_char())
i += 1
} else if c.to_int() == 34 {
i += 1
break
} else {
sb.write_char(c.unsafe_to_char())
i += 1
}
}
(sb.to_string(), i)
}
///|
fn scan_unquoted_array_element(raw : String, start : Int32) -> (String, Int32) {
let sb = StringBuilder()
let n = raw.length()
let mut i = start
while i < n {
let c = raw[i].to_int()
if c == 44 || c == 125 {
break
}
sb.write_char(raw[i].unsafe_to_char())
i += 1
}
(sb.to_string().trim().to_owned(), i)
}
///|
fn scan_nested_array(raw : String, start : Int32) -> (String, Int32) {
let n = raw.length()
let mut depth = 0
let mut i = start
let mut in_quote = false
while i < n {
let c = raw[i].to_int()
if in_quote {
if c == 92 && i + 1 < n {
i += 2
continue
}
if c == 34 {
in_quote = false
}
i += 1
} else if c == 34 {
in_quote = true
i += 1
} else if c == 123 {
depth += 1
i += 1
} else if c == 125 {
depth -= 1
i += 1
if depth == 0 {
return (raw[start:i].to_owned(), i)
}
} else {
i += 1
}
}
(raw[start:n].to_owned(), n)
}
///|
/// Parse a base-10 `Int`, or `None` if `s` is not a well-formed integer. Used to
/// decode `int2`/`int4` result text; a parse failure falls back to `Text` so a
/// surprising server rendering never silently becomes a wrong number.
pub fn parse_int(s : String) -> Int32? {
match parse_int64(s) {
Some(v) => Some(v.to_int())
None => None
}
}
///|
/// Parse a base-10 `Int64`, or `None` on any non-digit (after an optional sign).
pub fn parse_int64(s : String) -> Int64? {
let n = s.length()
if n == 0 {
return None
}
let mut i = 0
let mut neg = false
if s[0] == '-' {
neg = true
i = 1
} else if s[0] == '+' {
i = 1
}
if i >= n {
return None
}
let mut acc : Int64 = 0
while i < n {
let c = s[i].to_int()
if c < 48 || c > 57 {
return None
}
acc = acc * 10 + (c - 48).to_int64()
i += 1
}
Some(if neg { -acc } else { acc })
}
///|
/// Parse a floating-point literal from PostgreSQL text (`123.45`, `-1e10`,
/// `Infinity`, `NaN`), or `None` if malformed. Handles sign, fraction, and a
/// base-10 exponent; the special IEEE tokens PostgreSQL emits are recognised.
pub fn parse_double(s : String) -> Double? {
match s {
"NaN" => return Some(@double.not_a_number)
"Infinity" => return Some(@double.infinity)
"-Infinity" => return Some(@double.neg_infinity)
_ => ()
}
let n = s.length()
if n == 0 {
return None
}
let mut i = 0
let mut neg = false
if s[0] == '-' {
neg = true
i = 1
} else if s[0] == '+' {
i = 1
}
let mut int_part : Double = 0.0
let mut saw_digit = false
while i < n && s[i] >= '0' && s[i] <= '9' {
int_part = int_part * 10.0 + (s[i].to_int() - 48).to_double()
saw_digit = true
i += 1
}
let mut frac : Double = 0.0
let mut scale : Double = 1.0
if i < n && s[i] == '.' {
i += 1
while i < n && s[i] >= '0' && s[i] <= '9' {
frac = frac * 10.0 + (s[i].to_int() - 48).to_double()
scale = scale * 10.0
saw_digit = true
i += 1
}
}
if !saw_digit {
return None
}
let mut mantissa = int_part + frac / scale
if i < n && (s[i] == 'e' || s[i] == 'E') {
i += 1
let mut exp_neg = false
if i < n && s[i] == '-' {
exp_neg = true
i += 1
} else if i < n && s[i] == '+' {
i += 1
}
if i >= n {
return None
}
let mut exp = 0
while i < n && s[i] >= '0' && s[i] <= '9' {
exp = exp * 10 + (s[i].to_int() - 48)
i += 1
}
let mut factor : Double = 1.0
for _j in 0.. Bytes {
if s.has_prefix("\\x") {
@base16.decode_lossy(s[2:])
} else {
@utf8.encode(s)
}
}