Skip to content

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

# csv-to-sql — pure CSV → SQL import generator. Ruby port (canonical TS:
# src/lib/csv-to-sql.ts; Go twin: cli/csv-to-sql). Mirrors the JS/Python
# ports: RFC 4180 parse, header sanitizing, then batched INSERTs, a Postgres
# COPY block, or a MySQL LOAD DATA statement. Numeric text stays verbatim;
# empty fields become NULL. Errors return through the result hash.
module CsvToSql
  module_function

  NUMERIC = /^-?(\d+(\.\d+)?|\.\d+)([eE][+-]?\d+)?$/.freeze
  IDENT = /[^A-Za-z0-9_]/

  # RFC 4180 parser mirroring the TS loop: lenient quotes, CR dropped, a
  # trailing field without a newline still completes its row.
  def csv_to_rows(csv)
    rows = []
    field = +''
    row = []
    in_q = false
    i = 0
    n = csv.length
    while i < n
      ch = csv[i]
      if in_q
        if ch == '"'
          if i + 1 < n && csv[i + 1] == '"'
            field << '"'
            i += 1
          else
            in_q = false
          end
        else
          field << ch
        end
      elsif ch == '"'
        in_q = true
      elsif ch == ','
        row << field
        field = +''
      elsif ch == "\n"
        row << field
        rows << row
        row = []
        field = +''
      elsif ch != "\r"
        field << ch
      end
      i += 1
    end
    row << field if !field.empty? || !row.empty?
    rows << row if !row.empty?
    rows
  end

  def escape_sql_string(s, dialect = 'standard')
    out = s.gsub("'", "''")
    return out unless dialect == 'mysql'
    # Block-form gsub keeps replacements literal (no '\0' whole-match pitfalls).
    out.gsub('\\') { '\\\\' }.gsub("\0") { '\0' }.gsub("\n") { '\n' }
       .gsub("\r") { '\r' }.gsub("\x1a") { '\Z' }
  end

  def field_literal(value, dialect = 'standard', infer_types = true)
    if infer_types
      return 'NULL' if value == ''
      return value if NUMERIC.match?(value) # verbatim — no float round-trip
    end
    "'#{escape_sql_string(value, dialect)}'"
  end

  def sanitize_ident(name)
    name.gsub(IDENT, '_')
  end

  def sanitize_headers(headers)
    seen = Hash.new(0)
    headers.each_with_index.map do |h, i|
      id = sanitize_ident(h.strip)
      id = "col#{i + 1}" if id.empty?
      seen[id] += 1
      seen[id] > 1 ? "#{id}_#{seen[id]}" : id
    end
  end

  def csv_escape(field)
    return field unless field.match?(/[",\n\r]/)
    '"' + field.gsub('"', '""') + '"'
  end

  def csv_to_sql(csv, table:, format: 'insert', dialect: 'standard', batch_size: 100,
                 infer_types: true, quote_identifiers: true, file_name: 'import.csv')
    text = csv.strip
    rows = csv_to_rows(text)
    return { ok: false, sql: '', rows: 0, error: 'No rows to import.' } if text.empty? || rows.length < 2

    cols = sanitize_headers(rows[0])
    data = rows[1..]
    ident_dialect = { 'copy' => 'postgres', 'load-data' => 'mysql' }.fetch(format, dialect)
    q = lambda do |name|
      next name unless quote_identifiers
      ident_dialect == 'mysql' ? "`#{name}`" : "\"#{name}\""
    end
    tbl = q.call(sanitize_ident(table).empty? ? 'tbl' : sanitize_ident(table))
    col_list = cols.map { |c| q.call(c) }.join(', ')

    if %w[copy load-data].include?(format)
      payload = [cols.map { |c| csv_escape(c) }.join(',')]
      payload += data.map do |r|
        cols.each_index.map { |ci| csv_escape(r[ci] || '') }.join(',')
      end
      if format == 'copy'
        sql = "COPY #{tbl} (#{col_list}) FROM STDIN WITH (FORMAT csv, HEADER true);\n" +
              payload.join("\n") + "\n\\."
      else
        clean = file_name.gsub(/[^A-Za-z0-9._\-\/]/, '')
        clean = 'import.csv' if clean.empty?
        sql = "LOAD DATA LOCAL INFILE '#{clean}'\nINTO TABLE #{tbl}\n" \
              "FIELDS TERMINATED BY ',' OPTIONALLY ENCLOSED BY '\"'\n" \
              "LINES TERMINATED BY '\\n'\nIGNORE 1 LINES;\n\n" + payload.join("\n")
      end
      return { ok: true, sql: sql, rows: data.length, error: nil }
    end

    size = [1, batch_size].max
    stmts = []
    (0...data.length).step(size) do |start|
      values = data[start, size].map do |r|
        '  (' + cols.each_index.map { |ci| field_literal(r[ci] || '', dialect, infer_types) }.join(', ') + ')'
      end
      stmts << "INSERT INTO #{tbl} (#{col_list}) VALUES\n#{values.join(",\n")};"
    end
    { ok: true, sql: stmts.join("\n"), rows: data.length, error: nil }
  end
end

# Example:
# CsvToSql.csv_to_sql("id,name\n1,Ada\n2,", table: 'users')[:sql]
# → "INSERT INTO \"users\" (\"id\", \"name\") VALUES\n  (1, 'Ada'),\n  (2, 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 →