Skip to content

JSON to SQL INSERT — Rust source

Convert a JSON array of objects into SQL INSERT statements. Properly escapes strings, handles nulls, booleans, numbers, nested objects, and multi-row inserts.

This is the Rust implementation — the same logic the interactive tool runs, in a shareable, citable form.

// json-to-sql — Pure JSON → SQL INSERT converter.
// Language: Rust (edition 2021, std only).
//
// CosmoDev polyglot showcase port of the `json-to-sql` tool, ported from
// src/lib/jsonToSql.ts. Display source — part of CosmoDev's polyglot tool
// pages (https://dev.cosmolabs.org).
//
// Rust's standard library ships no JSON parser, so this file includes a
// small recursive-descent parser. Objects carry their keys in a Vec (not a
// HashMap), which preserves document order — that lets column ordering match
// the TypeScript port's Object.keys() first-seen semantics for free.
//
// The public API is total: malformed input is reported through
// ConvertResult.err, never via panic.

// ---------------------------------------------------------------------------
// Public configuration & result types
// ---------------------------------------------------------------------------

/// SQL flavour controlling escape rules and identifier quoting.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Dialect {
    Standard,
    Mysql,
    Postgres,
}

/// INSERT builder configuration. `Option` fields distinguish "unset" (apply
/// the default) from an explicit value.
pub struct Options {
    pub table: String,
    pub dialect: Option<Dialect>,
    pub quote_identifiers: Option<bool>,
}

impl Default for Options {
    fn default() -> Self {
        Self {
            table: String::new(),
            dialect: None,
            quote_identifiers: None,
        }
    }
}

/// Conversion outcome. `err` is `None` when `ok` is true. Mirrors the
/// TypeScript `Result` interface.
pub struct ConvertResult {
    pub ok: bool,
    pub sql: String,
    pub rows: usize,
    pub err: Option<String>,
}

// ---------------------------------------------------------------------------
// Minimal JSON value tree (document-order preserving)
// ---------------------------------------------------------------------------

#[derive(Clone, Debug)]
enum Json {
    Null,
    Bool(bool),
    Number(f64),
    Str(String),
    Array(Vec<Json>),
    /// `(key, value)` pairs in document order; duplicate keys keep their
    /// first position but hold the last value (JavaScript object semantics).
    Object(Vec<(String, Json)>),
}

impl Json {
    fn is_object(&self) -> bool {
        matches!(self, Json::Object(_))
    }

    /// Look up a key in an object, returning the last-written value.
    fn field(&self, key: &str) -> Option<&Json> {
        if let Json::Object(entries) = self {
            entries.iter().rev().find(|(k, _)| k == key).map(|(_, v)| v)
        } else {
            None
        }
    }
}

// ---------------------------------------------------------------------------
// Recursive-descent JSON parser
// ---------------------------------------------------------------------------

struct Parser<'a> {
    bytes: &'a [u8],
    pos: usize,
}

impl<'a> Parser<'a> {
    fn new(input: &'a str) -> Self {
        Self { bytes: input.as_bytes(), pos: 0 }
    }

    fn skip_ws(&mut self) {
        while self.pos < self.bytes.len() {
            match self.bytes[self.pos] {
                b' ' | b'\t' | b'\n' | b'\r' => self.pos += 1,
                _ => break,
            }
        }
    }

    fn peek(&self) -> Option<u8> {
        self.bytes.get(self.pos).copied()
    }

    fn parse_value(&mut self) -> Result<Json, String> {
        self.skip_ws();
        match self.peek() {
            Some(b'{') => self.parse_object(),
            Some(b'[') => self.parse_array(),
            Some(b'"') => Ok(Json::Str(self.parse_string()?)),
            Some(b't') | Some(b'f') => self.parse_bool(),
            Some(b'n') => self.parse_null(),
            Some(c) if c == b'-' || c.is_ascii_digit() => self.parse_number(),
            Some(_) => Err(format!("unexpected character at byte {}", self.pos)),
            None => Err("unexpected end of JSON input".into()),
        }
    }

    fn parse_object(&mut self) -> Result<Json, String> {
        self.pos += 1; // consume '{'
        let mut entries: Vec<(String, Json)> = Vec::new();
        self.skip_ws();
        if self.peek() == Some(b'}') {
            self.pos += 1;
            return Ok(Json::Object(entries));
        }
        loop {
            self.skip_ws();
            if self.peek() != Some(b'"') {
                return Err("expected string key in object".into());
            }
            let key = self.parse_string()?;
            self.skip_ws();
            if self.peek() != Some(b':') {
                return Err("expected ':' after object key".into());
            }
            self.pos += 1; // consume ':'
            let val = self.parse_value()?;
            // First-seen position, last-write value (JS object semantics).
            if let Some(slot) = entries.iter_mut().find(|(k, _)| k == &key) {
                slot.1 = val;
            } else {
                entries.push((key, val));
            }
            self.skip_ws();
            match self.peek() {
                Some(b',') => self.pos += 1,
                Some(b'}') => {
                    self.pos += 1;
                    break;
                }
                _ => return Err("expected ',' or '}' in object".into()),
            }
        }
        Ok(Json::Object(entries))
    }

    fn parse_array(&mut self) -> Result<Json, String> {
        self.pos += 1; // consume '['
        let mut items = Vec::new();
        self.skip_ws();
        if self.peek() == Some(b']') {
            self.pos += 1;
            return Ok(Json::Array(items));
        }
        loop {
            let val = self.parse_value()?;
            items.push(val);
            self.skip_ws();
            match self.peek() {
                Some(b',') => self.pos += 1,
                Some(b']') => {
                    self.pos += 1;
                    break;
                }
                _ => return Err("expected ',' or ']' in array".into()),
            }
        }
        Ok(Json::Array(items))
    }

    /// Parse a `"..."` literal, honouring all JSON escapes including
    /// UTF-16 surrogate pairs for code points outside the BMP.
    fn parse_string(&mut self) -> Result<String, String> {
        self.pos += 1; // consume opening quote
        let mut out = String::new();
        while let Some(c) = self.peek() {
            match c {
                b'"' => {
                    self.pos += 1;
                    return Ok(out);
                }
                b'\\' => {
                    self.pos += 1;
                    let e = self.peek().ok_or("truncated escape sequence")?;
                    self.pos += 1;
                    match e {
                        b'"' => out.push('"'),
                        b'\\' => out.push('\\'),
                        b'/' => out.push('/'),
                        b'b' => out.push('\u{0008}'),
                        b'f' => out.push('\u{000C}'),
                        b'n' => out.push('\n'),
                        b'r' => out.push('\r'),
                        b't' => out.push('\t'),
                        b'u' => out.push(self.parse_unicode_escape()?),
                        _ => return Err(format!("invalid escape \\{}", e as char)),
                    }
                }
                _ => {
                    // Copy a run of literal bytes verbatim up to the next
                    // structural character; validate as UTF-8 in one shot.
                    let start = self.pos;
                    self.pos += 1;
                    while let Some(cc) = self.peek() {
                        if cc == b'"' || cc == b'\\' {
                            break;
                        }
                        self.pos += 1;
                    }
                    out.push_str(
                        std::str::from_utf8(&self.bytes[start..self.pos])
                            .map_err(|_| "invalid UTF-8 in string".to_string())?,
                    );
                }
            }
        }
        Err("unterminated string".into())
    }

    /// Parse the 4 hex digits following `\u`, and a trailing low surrogate if
    /// the first code unit is a high surrogate.
    fn parse_unicode_escape(&mut self) -> Result<char, String> {
        let hi = self.parse_hex4()?;
        if (0xD800..=0xDBFF).contains(&hi) {
            // High surrogate — require a paired `\uXXXX` low surrogate.
            if self.peek() == Some(b'\\') {
                self.pos += 1;
                if self.peek() == Some(b'u') {
                    self.pos += 1;
                    let lo = self.parse_hex4()?;
                    if (0xDC00..=0xDFFF).contains(&lo) {
                        let code = 0x10000 + (((hi as u32) - 0xD800) << 10)
                            + (lo as u32) - 0xDC00;
                        return char::from_u32(code)
                            .ok_or_else(|| "invalid surrogate pair".into());
                    }
                }
            }
            Err("unpaired high surrogate".into())
        } else if (0xDC00..=0xDFFF).contains(&hi) {
            Err("unpaired low surrogate".into())
        } else {
            char::from_u32(hi as u32).ok_or_else(|| "invalid code point".into())
        }
    }

    fn parse_hex4(&mut self) -> Result<u16, String> {
        let mut v: u16 = 0;
        for _ in 0..4 {
            let c = self.peek().ok_or("truncated \\u escape")?;
            self.pos += 1;
            let d = match c {
                b'0'..=b'9' => (c - b'0') as u16,
                b'a'..=b'f' => (c - b'a' + 10) as u16,
                b'A'..=b'F' => (c - b'A' + 10) as u16,
                _ => return Err("invalid hex digit in \\u escape".into()),
            };
            v = v.checked_mul(16).ok_or("\\u overflow")? + d;
        }
        Ok(v)
    }

    fn parse_number(&mut self) -> Result<Json, String> {
        let start = self.pos;
        if self.peek() == Some(b'-') {
            self.pos += 1;
        }
        while let Some(c) = self.peek() {
            match c {
                b'0'..=b'9' | b'.' | b'e' | b'E' | b'+' | b'-' => self.pos += 1,
                _ => break,
            }
        }
        let text = std::str::from_utf8(&self.bytes[start..self.pos])
            .map_err(|_| "invalid UTF-8 in number".to_string())?;
        let f: f64 = text
            .parse()
            .map_err(|_| format!("invalid number literal: {}", text))?;
        Ok(Json::Number(f))
    }

    fn parse_bool(&mut self) -> Result<Json, String> {
        if self.bytes[self.pos..].starts_with(b"true") {
            self.pos += 4;
            Ok(Json::Bool(true))
        } else if self.bytes[self.pos..].starts_with(b"false") {
            self.pos += 5;
            Ok(Json::Bool(false))
        } else {
            Err("invalid literal".into())
        }
    }

    fn parse_null(&mut self) -> Result<Json, String> {
        if self.bytes[self.pos..].starts_with(b"null") {
            self.pos += 4;
            Ok(Json::Null)
        } else {
            Err("invalid literal".into())
        }
    }
}

/// Parse a complete JSON document, rejecting trailing garbage.
fn parse(input: &str) -> Result<Json, String> {
    let mut p = Parser::new(input);
    let v = p.parse_value()?;
    p.skip_ws();
    if p.pos != p.bytes.len() {
        return Err(format!("trailing data at byte {}", p.pos));
    }
    Ok(v)
}

// ---------------------------------------------------------------------------
// Compact JSON re-serializer (for the object/array literal fallback)
// ---------------------------------------------------------------------------

/// Render a Json value back to compact JSON text, matching JavaScript's
/// `JSON.stringify` (no whitespace, `/` left unescaped, control chars as
/// `\uXXXX`, non-ASCII code points emitted literally).
fn stringify(value: &Json) -> String {
    let mut out = String::new();
    stringify_into(value, &mut out);
    out
}

fn stringify_into(value: &Json, out: &mut String) {
    match value {
        Json::Null => out.push_str("null"),
        Json::Bool(b) => out.push_str(if *b { "true" } else { "false" }),
        Json::Number(f) => {
            // JSON forbids NaN/Infinity — emit null, exactly as JS does.
            if !f.is_finite() {
                out.push_str("null");
            } else {
                out.push_str(&format_number(*f));
            }
        }
        Json::Str(s) => {
            out.push('"');
            for c in s.chars() {
                match c {
                    '"' => out.push_str("\\\""),
                    '\\' => out.push_str("\\\\"),
                    '\n' => out.push_str("\\n"),
                    '\r' => out.push_str("\\r"),
                    '\t' => out.push_str("\\t"),
                    '\u{0008}' => out.push_str("\\b"),
                    '\u{000C}' => out.push_str("\\f"),
                    c if (c as u32) < 0x20 => {
                        out.push_str(&format!("\\u{:04x}", c as u32));
                    }
                    c => out.push(c),
                }
            }
            out.push('"');
        }
        Json::Array(items) => {
            out.push('[');
            for (i, it) in items.iter().enumerate() {
                if i > 0 {
                    out.push(',');
                }
                stringify_into(it, out);
            }
            out.push(']');
        }
        Json::Object(entries) => {
            out.push('{');
            for (i, (k, v)) in entries.iter().enumerate() {
                if i > 0 {
                    out.push(',');
                }
                // Object keys are JSON strings — reuse the string serializer.
                stringify_into(&Json::Str(k.clone()), out);
                out.push(':');
                stringify_into(v, out);
            }
            out.push('}');
        }
    }
}

// ---------------------------------------------------------------------------
// SQL conversion
// ---------------------------------------------------------------------------

/// Mirror JavaScript's `String(number)`: integer-valued floats render without
/// a decimal point (`1.0` → `"1"`); fractional values use Rust's shortest
/// round-trippable float Display.
fn format_number(f: f64) -> String {
    if f.fract() == 0.0 && f.abs() < 1e16 {
        format!("{}", f as i64)
    } else {
        format!("{}", f)
    }
}

/// Escape a string for a single-quoted SQL literal. Single quotes are always
/// doubled; MySQL mode additionally neutralises the backslash and the
/// control bytes that terminate a MySQL statement.
pub fn escape_sql_string(s: &str, dialect: Dialect) -> String {
    let mut out = s.replace('\'', "''");
    if dialect == Dialect::Mysql {
        // Backslash first so the escapes added below aren't re-doubled.
        out = out.replace('\\', "\\\\");
        out = out.replace('\u{0000}', "\\0");
        out = out.replace('\n', "\\n");
        out = out.replace('\r', "\\r");
        out = out.replace('\u{001A}', "\\Z"); // SUB — ends MySQL input
    }
    out
}

/// Render a JSON value as a SQL literal.
pub fn sql_literal(value: &Json, dialect: Dialect) -> String {
    match value {
        Json::Null => "NULL".to_string(),
        Json::Bool(b) => {
            if dialect == Dialect::Mysql {
                if *b { "1".into() } else { "0".into() }
            } else if *b {
                "TRUE".into()
            } else {
                "FALSE".into()
            }
        }
        Json::Number(f) => {
            if !f.is_finite() {
                "NULL".into()
            } else {
                format_number(*f)
            }
        }
        Json::Str(s) => format!("'{}'", escape_sql_string(s, dialect)),
        // Objects/arrays → compact JSON text literal.
        other => format!("'{}'", escape_sql_string(&stringify(other), dialect)),
    }
}

/// Wrap an identifier in dialect-appropriate quotes, or leave it bare when
/// identifier quoting is disabled.
fn quote_ident(name: &str, dialect: Dialect, quote: bool) -> String {
    if !quote {
        return name.to_string();
    }
    match dialect {
        Dialect::Mysql => format!("`{}`", name),
        _ => format!("\"{}\"", name),
    }
}

/// Reduce an identifier to [A-Za-z0-9_], substituting `_` for everything else
/// and defaulting to "tbl" when nothing usable remains.
fn sanitize_ident(name: &str) -> String {
    let cleaned: String = name
        .chars()
        .map(|c| {
            if c.is_ascii_alphanumeric() || c == '_' {
                c
            } else {
                '_'
            }
        })
        .collect();
    if cleaned.is_empty() {
        "tbl".to_string()
    } else {
        cleaned
    }
}

/// Convert a JSON document into a single multi-row INSERT statement.
///
/// A bare JSON object is treated as one row; a JSON array as many. Rows may
/// differ in shape — the column list is the union of every key in first-seen
/// order, and missing values are emitted as NULL.
pub fn json_to_insert(json_string: &str, opts: &Options) -> ConvertResult {
    let parsed = match parse(json_string) {
        Ok(v) => v,
        Err(e) => {
            return ConvertResult {
                ok: false,
                sql: String::new(),
                rows: 0,
                err: Some(e),
            }
        }
    };

    // Borrow the row set from the parsed tree: array → many, else → one.
    let rows: Vec<&Json> = match &parsed {
        Json::Array(items) => items.iter().collect(),
        other => vec![other],
    };

    if rows.is_empty() {
        return ConvertResult {
            ok: false,
            sql: String::new(),
            rows: 0,
            err: Some("No rows to insert.".into()),
        };
    }
    if !rows.iter().all(|r| r.is_object()) {
        return ConvertResult {
            ok: false,
            sql: String::new(),
            rows: 0,
            err: Some("Rows must be objects.".into()),
        };
    }

    let dialect = opts.dialect.unwrap_or(Dialect::Standard);
    let quote = opts.quote_identifiers.unwrap_or(true);
    let table = quote_ident(&sanitize_ident(&opts.table), dialect, quote);

    // Union of every row's keys in first-seen order.
    let mut cols: Vec<String> = Vec::new();
    for row in &rows {
        if let Json::Object(entries) = row {
            for (k, _) in entries {
                if !cols.iter().any(|c| c == k) {
                    cols.push(k.clone());
                }
            }
        }
    }
    let col_list = cols
        .iter()
        .map(|c| quote_ident(c, dialect, quote))
        .collect::<Vec<_>>()
        .join(", ");

    let null = Json::Null;
    let value_lines: Vec<String> = rows
        .iter()
        .map(|row| {
            let vals: Vec<String> = cols
                .iter()
                .map(|c| {
                    let val = row.field(c).unwrap_or(&null);
                    sql_literal(val, dialect)
                })
                .collect();
            format!("  ({})", vals.join(", "))
        })
        .collect();

    let sql = format!(
        "INSERT INTO {} ({}) VALUES\n{};",
        table,
        col_list,
        value_lines.join(",\n")
    );
    ConvertResult {
        ok: true,
        sql,
        rows: rows.len(),
        err: None,
    }
}

Also available in 13 other languages

Every CosmoDev tool ships its pure logic in TypeScript (web) and Go (CLI), with authored implementations in a dozen-plus languages — the same contract, ported. Compare all languages side by side →