Skip to content

CSV to SQL Importer — TypeScript source

Turn CSV data into SQL import statements: batched multi-row INSERTs, a Postgres COPY FROM STDIN block, or a MySQL LOAD DATA statement. Infers numeric columns, emits NULL for empty fields, sanitizes and de-duplicates header names into SQL identifiers.

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

// Pure CSV → SQL import generator - no React, no DOM, deterministic. The
// unit-test surface for the CSV to SQL Importer tool. Reuses the RFC 4180
// parser from ./csv (csvToRows); emits three formats: batched multi-row
// INSERT statements, a Postgres COPY FROM STDIN block, and a MySQL LOAD DATA
// statement with the CSV payload. Numeric-looking fields stay bare and
// verbatim (no float round-trip, so the Go twin can match byte-for-byte),
// empty fields become NULL, everything else is an escaped string literal.
// Never throws.

import { csvToRows } from './csv';

export type Dialect = 'standard' | 'mysql' | 'postgres';
export type Format = 'insert' | 'copy' | 'load-data';

export interface Options {
  table: string;
  /** Output format. Default 'insert'. */
  format?: Format;
  /** Identifier quoting + string escaping for INSERT. Default 'standard'.
   *  COPY always uses Postgres rules; LOAD DATA always uses MySQL rules. */
  dialect?: Dialect;
  /** Rows per INSERT statement. Default 100; values <= 0 fall back to 100. */
  batchSize?: number;
  /** When true (default): numeric-looking text emits bare, '' emits NULL.
   *  When false: every field is a quoted string (empty stays ''). */
  inferTypes?: boolean;
  /** Quote identifiers ("col" / `col`). Default true. */
  quoteIdentifiers?: boolean;
  /** File referenced by LOAD DATA output. Default 'import.csv'. */
  fileName?: string;
}

export interface Result {
  ok: boolean;
  sql: string;
  rows: number;
  error: string | null;
}

/** Numeric literal shape (integer, decimal, or scientific). Matched text is
 *  emitted VERBATIM - "007", "1e3", ".5" all pass through unchanged, which
 *  keeps TypeScript and Go byte-identical (no float formatting ever). */
const NUMERIC_RE = /^-?(?:\d+(?:\.\d+)?|\.\d+)(?:[eE][+-]?\d+)?$/;

/** Escape a single-quoted SQL literal: '' doubling everywhere; MySQL mode
 *  additionally escapes the backslash and statement-hostile control bytes.
 *  (Same contract as jsonToSql's escapeSqlString, kept local so this lib and
 *  its ports stay self-contained.) */
export function escapeSqlString(s: string, dialect: Dialect = 'standard'): string {
  let out = s.replace(/'/g, "''");
  if (dialect === 'mysql') {
    out = out
      .replace(/\\/g, '\\\\')
      .replace(/\0/g, '\\0')
      .replace(/\n/g, '\\n')
      .replace(/\r/g, '\\r')
      .replace(/\x1a/g, '\\Z');
  }
  return out;
}

/** Render one CSV field as an INSERT value literal. */
export function fieldLiteral(
  value: string,
  opts: { dialect?: Dialect; inferTypes?: boolean } = {},
): string {
  const dialect = opts.dialect ?? 'standard';
  if (opts.inferTypes ?? true) {
    if (value === '') return 'NULL';
    if (NUMERIC_RE.test(value)) return value;
  }
  return `'${escapeSqlString(value, dialect)}'`;
}

/** Replace characters SQL identifiers cannot carry with '_'. */
export function sanitizeIdent(name: string): string {
  return (name || '').replace(/[^A-Za-z0-9_]/g, '_');
}

function quoteIdent(name: string, dialect: Dialect, quoteIdentifiers: boolean): string {
  if (!quoteIdentifiers) return name;
  return dialect === 'mysql' ? `\`${name}\`` : `"${name}"`;
}

/** Header row -> SQL column names: trim, sanitize, empty -> col<N>, and
 *  de-duplicate deterministically (second occurrence gets _2, then _3...). */
function sanitizeHeaders(headers: string[]): string[] {
  const out: string[] = [];
  const seen = new Map<string, number>();
  headers.forEach((h, i) => {
    let id = sanitizeIdent(h.trim());
    if (!id) id = `col${i + 1}`;
    const n = (seen.get(id) ?? 0) + 1;
    seen.set(id, n);
    if (n > 1) id = `${id}_${n}`;
    out.push(id);
  });
  return out;
}

/** Quote a CSV field for re-emission (RFC 4180): quote when it carries a
 *  comma, quote, or newline; double embedded quotes. */
function csvEscape(field: string): string {
  if (/[",\n\r]/.test(field)) return '"' + field.replace(/"/g, '""') + '"';
  return field;
}

/** LOAD DATA file reference: keep path-safe characters only. */
function sanitizeFileName(name: string): string {
  const cleaned = (name || '').replace(/[^A-Za-z0-9._\-/]/g, '');
  return cleaned || 'import.csv';
}

export function csvToSql(csv: string, opts: Options): Result {
  const fail = (error: string): Result => ({ ok: false, sql: '', rows: 0, error });

  const text = csv.trim();
  if (!text) return fail('No rows to import.');

  // csvToRows on trimmed non-empty text always yields >= 1 row.
  const rows = csvToRows(text);
  const headers = rows[0];
  const data = rows.slice(1);
  if (data.length === 0) return fail('No data rows below the header.');

  const format = opts.format ?? 'insert';
  const dialect = opts.dialect ?? 'standard';
  const inferTypes = opts.inferTypes ?? true;
  const quoteIdentifiers = opts.quoteIdentifiers ?? true;

  const cols = sanitizeHeaders(headers);
  // Quoting semantics follow the format: INSERT honors the dialect option,
  // COPY is Postgres, LOAD DATA is MySQL.
  const identDialect: Dialect = format === 'copy' ? 'postgres' : format === 'load-data' ? 'mysql' : dialect;
  const table = quoteIdent(sanitizeIdent(opts.table) || 'tbl', identDialect, quoteIdentifiers);
  const colList = cols.map((c) => quoteIdent(c, identDialect, quoteIdentifiers)).join(', ');

  if (format === 'copy' || format === 'load-data') {
    // Payload: sanitized header + data rows, re-emitted as clean CSV (an
    // unquoted empty field is NULL under COPY csv semantics).
    const payload = [
      cols.map(csvEscape).join(','),
      ...data.map((row) => cols.map((_, ci) => csvEscape(row[ci] ?? '')).join(',')),
    ];
    if (format === 'copy') {
      const sql =
        `COPY ${table} (${colList}) FROM STDIN WITH (FORMAT csv, HEADER true);\n` +
        `${payload.join('\n')}\n\\.`;
      return { ok: true, sql, rows: data.length, error: null };
    }
    const sql =
      `LOAD DATA LOCAL INFILE '${sanitizeFileName(opts.fileName ?? '')}'\n` +
      `INTO TABLE ${table}\n` +
      `FIELDS TERMINATED BY ',' OPTIONALLY ENCLOSED BY '"'\n` +
      `LINES TERMINATED BY '\\n'\n` +
      `IGNORE 1 LINES;\n\n${payload.join('\n')}`;
    return { ok: true, sql, rows: data.length, error: null };
  }

  // INSERT: batched multi-row statements.
  const batchSize = opts.batchSize && opts.batchSize > 0 ? opts.batchSize : 100;
  const stmts: string[] = [];
  for (let i = 0; i < data.length; i += batchSize) {
    const chunk = data.slice(i, i + batchSize);
    const values = chunk.map(
      (row) =>
        `  (${cols.map((_, ci) => fieldLiteral(row[ci] ?? '', { dialect, inferTypes })).join(', ')})`,
    );
    stmts.push(`INSERT INTO ${table} (${colList}) VALUES\n${values.join(',\n')};`);
  }
  return { ok: true, sql: stmts.join('\n'), rows: data.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 →