JSON to SQL INSERT — Go 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 Go implementation — the same logic the interactive tool runs, in a shareable, citable form.
// Package jsontosql is the Go twin of CosmoDev's src/lib/jsonToSql.ts (dual
// source: the web lib is TypeScript, the CLI lib is Go — kept in lock-step).
// Pure + deterministic, never panics. The table-driven tests in
// json-to-sql_test.go share vectors with src/lib/jsonToSql.test.ts so the two
// implementations are held to the same contract.
//
// The converter mirrors the TS lib exactly: double single quotes (and, for
// MySQL, escape backslashes + the control bytes \0 \n \r \x1a), render
// null/boolean/number/object/array values as SQL literals, gather the union of
// object keys in first-seen order, and emit one multi-row INSERT statement. JSON
// object keys are decoded in document order (via an ordered decoder) so column
// order matches JS Object.keys insertion order.
package jsontosql
import (
"bytes"
"encoding/json"
"fmt"
"io"
"math"
"regexp"
"strconv"
"strings"
)
// Dialect selects the SQL dialect for identifier quoting and string escaping.
type Dialect string
const (
// DialectStandard is the zero value, matching the TS default
// (dialect: 'standard'). Standard and Postgres use double-quoted
// identifiers and TRUE/FALSE booleans.
DialectStandard Dialect = "standard"
DialectMysql Dialect = "mysql"
DialectPostgres Dialect = "postgres"
)
// Options configures JsonToInsert. Table is required. Dialect and
// QuoteIdentifiers mirror the TS option object; their zero values resolve to the
// TS defaults (standard dialect, identifiers quoted).
//
// QuoteIdentifiers is a *bool so the zero value means the TS default (true)
// while a non-nil false still means "do not quote" — exactly like the TS lib's
// distinction between omitted (→ true) and false (→ unquoted).
type Options struct {
Table string
Dialect Dialect // "" → "standard" (default)
QuoteIdentifiers *bool // nil → true (default); non-nil used verbatim
}
// Result is the outcome of a conversion. Error is empty when Ok is true (the TS
// lib uses null there).
type Result struct {
Ok bool
Sql string
Rows int
Error string
}
// OrderedMap is a JSON object decoded with its keys in document order (unlike
// map[string]interface{}, whose iteration order is randomized). It is internal
// to the decoder so the emitted column order matches JS Object.keys insertion
// order.
type OrderedMap struct {
Keys []string
Values map[string]interface{}
}
// MarshalJSON emits the object compact, keys in document order, no HTML escaping
// — mirroring JSON.stringify on the TS side.
func (om *OrderedMap) MarshalJSON() ([]byte, error) {
var buf bytes.Buffer
buf.WriteByte('{')
for i, k := range om.Keys {
if i > 0 {
buf.WriteByte(',')
}
kb, err := json.Marshal(k)
if err != nil {
return nil, err
}
buf.Write(kb)
buf.WriteByte(':')
vb, err := json.Marshal(om.Values[k])
if err != nil {
return nil, err
}
buf.Write(vb)
}
buf.WriteByte('}')
return buf.Bytes(), nil
}
// EscapeSqlString doubles single quotes (every dialect) and, for MySQL, escapes
// backslashes and the control bytes \0 \n \r \x1a. It is the Go twin of
// escapeSqlString() in src/lib/jsonToSql.ts and must agree on every shared
// vector.
func EscapeSqlString(s string, dialect Dialect) string {
out := strings.ReplaceAll(s, "'", "''")
if dialect == DialectMysql {
out = strings.ReplaceAll(out, "\\", "\\\\")
out = strings.ReplaceAll(out, "\x00", "\\0")
out = strings.ReplaceAll(out, "\n", "\\n")
out = strings.ReplaceAll(out, "\r", "\\r")
out = strings.ReplaceAll(out, "\x1a", "\\Z")
}
return out
}
// SqlLiteral renders a single decoded JSON value as a SQL literal. It is the Go
// twin of sqlLiteral() in src/lib/jsonToSql.ts: null → NULL, booleans →
// TRUE/FALSE (1/0 for MySQL), numbers → their text form (non-finite → NULL),
// strings → quoted+escaped, objects/arrays → quoted+escaped JSON text.
func SqlLiteral(value interface{}, dialect Dialect) string {
if value == nil {
return "NULL"
}
switch v := value.(type) {
case bool:
if dialect == DialectMysql {
if v {
return "1"
}
return "0"
}
if v {
return "TRUE"
}
return "FALSE"
case float64:
if math.IsNaN(v) || math.IsInf(v, 0) {
return "NULL"
}
return jsNumberString(v)
case string:
return "'" + EscapeSqlString(v, dialect) + "'"
default:
// objects (*OrderedMap) / arrays ([]interface{}) → JSON text literal
s, err := jsonString(v)
if err != nil {
return "NULL" // mirror the TS lib: never throw
}
return "'" + EscapeSqlString(s, dialect) + "'"
}
}
var nonIdentChar = regexp.MustCompile(`[^A-Za-z0-9_]`)
// sanitizeIdent replaces every non [A-Za-z0-9_] rune with '_' and falls back to
// "tbl" for an empty result — mirroring sanitizeIdent() in the TS lib.
func sanitizeIdent(name string) string {
cleaned := nonIdentChar.ReplaceAllString(name, "_")
if cleaned == "" {
return "tbl"
}
return cleaned
}
// quoteIdent wraps an identifier in the dialect's quoting characters (or leaves
// it bare when quoteIdentifiers is false) — mirroring quoteIdent() in the TS lib.
func quoteIdent(name string, dialect Dialect, quoteIdentifiers bool) string {
if !quoteIdentifiers {
return name
}
if dialect == DialectMysql {
return "`" + name + "`"
}
return "\"" + name + "\""
}
// jsNumberString formats a float64 the way JS String(number) does for the common
// cases: integers within ±1e21 print without a decimal point; everything else
// uses the shortest round-tripping form.
func jsNumberString(v float64) string {
if v == math.Trunc(v) && math.Abs(v) < 1e21 {
return strconv.FormatFloat(v, 'f', -1, 64)
}
return strconv.FormatFloat(v, 'g', -1, 64)
}
// jsonString serializes a nested value the way TS JSON.stringify does — compact
// and without HTML escaping — for embedding as a JSON text literal. Object keys
// stay in document order via OrderedMap.MarshalJSON.
func jsonString(v interface{}) (string, error) {
var buf bytes.Buffer
enc := json.NewEncoder(&buf)
enc.SetEscapeHTML(false)
if err := enc.Encode(v); err != nil {
return "", err
}
// json.Encoder.Encode appends a trailing newline; strip it.
return strings.TrimRight(buf.String(), "\n"), nil
}
// JsonToInsert parses a JSON string (array of row objects, or a single object)
// and builds a single multi-row INSERT statement. It is the Go twin of
// jsonToInsert() in src/lib/jsonToSql.ts and must agree on every shared vector.
func JsonToInsert(jsonString string, opts Options) Result {
data, err := decodeOrdered(strings.NewReader(jsonString))
if err != nil {
return Result{Ok: false, Error: err.Error()}
}
var arr []interface{}
if d, ok := data.([]interface{}); ok {
arr = d
} else {
arr = []interface{}{data}
}
if len(arr) == 0 {
return Result{Ok: false, Error: "No rows to insert."}
}
for _, r := range arr {
if _, ok := r.(*OrderedMap); !ok {
return Result{Ok: false, Error: "Rows must be objects."}
}
}
dialect := opts.Dialect
if dialect == "" {
dialect = DialectStandard
}
quoteIdents := true
if opts.QuoteIdentifiers != nil {
quoteIdents = *opts.QuoteIdentifiers
}
table := quoteIdent(sanitizeIdent(opts.Table), dialect, quoteIdents)
// Union of keys, first-seen order (object keys decoded in document order).
var cols []string
seen := map[string]bool{}
for _, r := range arr {
om := r.(*OrderedMap)
for _, k := range om.Keys {
if !seen[k] {
seen[k] = true
cols = append(cols, k)
}
}
}
quotedCols := make([]string, len(cols))
for i, c := range cols {
quotedCols[i] = quoteIdent(c, dialect, quoteIdents)
}
colList := strings.Join(quotedCols, ", ")
valueLists := make([]string, len(arr))
for i, r := range arr {
om := r.(*OrderedMap)
vals := make([]string, len(cols))
for j, c := range cols {
v, ok := om.Values[c]
if !ok {
v = nil
}
vals[j] = SqlLiteral(v, dialect)
}
valueLists[i] = " (" + strings.Join(vals, ", ") + ")"
}
sql := "INSERT INTO " + table + " (" + colList + ") VALUES\n" + strings.Join(valueLists, ",\n") + ";"
return Result{Ok: true, Sql: sql, Rows: len(arr)}
}
// decodeOrdered reads one JSON value, preserving object key order via
// *OrderedMap. It is the order-preserving analogue of json.Unmarshal into
// interface{}: objects → *OrderedMap, arrays → []interface{}, numbers →
// float64, booleans → bool, strings → string, null → nil.
func decodeOrdered(r io.Reader) (interface{}, error) {
dec := json.NewDecoder(r)
return parseValue(dec)
}
// parseValue reads a single JSON value from the decoder stream.
func parseValue(dec *json.Decoder) (interface{}, error) {
t, err := dec.Token()
if err != nil {
return nil, err
}
delim, isDelim := t.(json.Delim)
if !isDelim {
// primitive token: bool, float64, string, or nil (JSON null)
return t, nil
}
switch delim {
case '{':
om := &OrderedMap{Values: map[string]interface{}{}}
for dec.More() {
kTok, err := dec.Token()
if err != nil {
return nil, err
}
key, ok := kTok.(string)
if !ok {
return nil, fmt.Errorf("json: expected string object key")
}
val, err := parseValue(dec)
if err != nil {
return nil, err
}
if _, exists := om.Values[key]; !exists {
om.Keys = append(om.Keys, key)
}
om.Values[key] = val
}
// consume the closing '}'
if _, err := dec.Token(); err != nil {
return nil, err
}
return om, nil
case '[':
var arr []interface{}
for dec.More() {
val, err := parseValue(dec)
if err != nil {
return nil, err
}
arr = append(arr, val)
}
// consume the closing ']'
if _, err := dec.Token(); err != nil {
return nil, err
}
return arr, nil
}
return nil, fmt.Errorf("json: unexpected delimiter %q", delim)
}
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 →