Skip to content

JSON to SQL INSERT — JavaScript 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 JavaScript implementation — the same logic the interactive tool runs, in a shareable, citable form.

/**
 * json-to-sql - Pure JSON → SQL INSERT converter.
 * Language: JavaScript (ES module).
 *
 * 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).
 *
 * Design notes:
 *   - Pure & side-effect free. Never throws: malformed JSON is reported via
 *     the returned `error` field, matching the TypeScript contract exactly.
 *   - Strings: single quotes are always doubled (' -> ''). MySQL mode
 *     additionally escapes the backslash and the control bytes that can
 *     terminate a statement (NUL, LF, CR, SUB).
 *   - Scalars: null/undefined -> NULL; booleans -> TRUE/FALSE (1/0 in MySQL);
 *     non-finite numbers -> NULL; numbers rendered via String() semantics.
 *   - Objects/arrays become a JSON text literal (compact, unescaped Unicode).
 */

/** @typedef {'standard' | 'mysql' | 'postgres'} Dialect */

/**
 * INSERT builder configuration.
 * @typedef {Object} Options
 * @property {string} table                       Target table name (sanitized).
 * @property {Dialect} [dialect='standard']       SQL flavour for escaping/quoting.
 * @property {boolean} [quoteIdentifiers=true]    Wrap identifiers in quotes.
 */

/**
 * Conversion result.
 * @typedef {Object} Result
 * @property {boolean} ok
 * @property {string} sql
 * @property {number} rows
 * @property {string|null} error
 */

/**
 * Escape a string for safe inclusion in a single-quoted SQL literal.
 * Single quotes are always doubled; MySQL mode additionally neutralises the
 * backslash and the statement-terminating control bytes.
 *
 * @param {string} s
 * @param {Dialect} [dialect='standard']
 * @returns {string}
 */
export function escapeSqlString(s, dialect = 'standard') {
  // ANSI behaviour everywhere: doubling the quote neutralises it.
  let out = s.replace(/'/g, "''");
  if (dialect === 'mysql') {
    // Backslash is escaped FIRST, so the backslashes introduced by the
    // replacements below are not themselves re-doubled (matches the TS order).
    out = out
      .replace(/\\/g, '\\\\')   // \  -> \\
      .replace(/\0/g, '\\0')    // NUL
      .replace(/\n/g, '\\n')    // LF
      .replace(/\r/g, '\\r')    // CR
      .replace(/\x1a/g, '\\Z'); // SUB - ends input on some MySQL clients
  }
  return out;
}

/**
 * Render a JavaScript value as a SQL literal.
 *
 * @param {unknown} value
 * @param {Dialect} dialect
 * @returns {string}
 */
export function sqlLiteral(value, dialect) {
  if (value === null || value === undefined) return 'NULL';
  if (typeof value === 'boolean') {
    // MySQL has no BOOL literal - it aliases TINYINT(1).
    return dialect === 'mysql' ? (value ? '1' : '0') : value ? 'TRUE' : 'FALSE';
  }
  if (typeof value === 'number') {
    // NaN / ±Infinity cannot be represented as SQL number literals.
    return Number.isFinite(value) ? String(value) : 'NULL';
  }
  if (typeof value === 'string') {
    return `'${escapeSqlString(value, dialect)}'`;
  }
  // Plain objects and arrays are stored verbatim as JSON text.
  return `'${escapeSqlString(JSON.stringify(value), dialect)}'`;
}

/**
 * Quote an identifier per dialect, or leave it bare when quoting is disabled.
 */
function quoteIdent(name, dialect, quoteIdentifiers) {
  if (!quoteIdentifiers) return name;
  return dialect === 'mysql' ? `\`${name}\`` : `"${name}"`;
}

/**
 * Reduce an identifier to the safe subset [A-Za-z0-9_], defaulting to "tbl"
 * when nothing usable remains. Runs before quoting, even when quoting is off.
 */
function sanitizeIdent(name) {
  const cleaned = String(name ?? '').replace(/[^A-Za-z0-9_]/g, '_');
  return cleaned || 'tbl';
}

/**
 * Convert a JSON string into a single multi-row INSERT statement.
 *
 * A bare JSON object is treated as one row; a JSON array as many. Rows may
 * have differing shapes - the column list is the union of every key in
 * first-seen order, and missing values are emitted as NULL.
 *
 * @param {string} jsonString
 * @param {Options} opts
 * @returns {Result}
 */
export function jsonToInsert(jsonString, opts) {
  let data;
  try {
    data = JSON.parse(jsonString);
  } catch (e) {
    return { ok: false, sql: '', rows: 0, error: e.message };
  }

  // A bare object is one row; an array is many.
  const arr = Array.isArray(data) ? data : [data];
  if (arr.length === 0) {
    return { ok: false, sql: '', rows: 0, error: 'No rows to insert.' };
  }
  if (!arr.every((r) => r !== null && typeof r === 'object' && !Array.isArray(r))) {
    return { ok: false, sql: '', rows: 0, error: 'Rows must be objects.' };
  }

  const dialect = opts.dialect ?? 'standard';
  const quoteIdentifiers = opts.quoteIdentifiers ?? true;
  const table = quoteIdent(sanitizeIdent(opts.table), dialect, quoteIdentifiers);

  // Union of every row's keys, first-seen order. A column absent from a given
  // row is emitted as NULL below, so heterogeneous schemas unify cleanly.
  const cols = [];
  for (const row of arr) {
    for (const k of Object.keys(row)) {
      if (!cols.includes(k)) cols.push(k);
    }
  }
  const colList = cols.map((c) => quoteIdent(c, dialect, quoteIdentifiers)).join(', ');

  const valueLists = arr.map((row) => {
    const vals = cols.map((c) => sqlLiteral(c in row ? row[c] : null, dialect));
    return `  (${vals.join(', ')})`;
  });

  const sql = `INSERT INTO ${table} (${colList}) VALUES\n${valueLists.join(',\n')};`;
  return { ok: true, sql, rows: arr.length, error: null };
}

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 →