Skip to content

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

// csv-to-sql — pure CSV → SQL import generator. C# port (canonical TS:
// src/lib/csv-to-sql.ts; Go twin: cli/csv-to-sql). RFC 4180 parse, header
// sanitizing into SQL identifiers, then batched INSERTs, a Postgres COPY
// block, or a MySQL LOAD DATA statement. Numeric-looking text emits bare and
// verbatim; empty fields become NULL.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Text.RegularExpressions;

public static class CsvToSql
{
    /// RFC 4180 parser mirroring the TS loop: lenient quotes, CR dropped, a
    /// trailing field without a newline still completes its row.
    public static List<string[]> CsvToRows(string csv)
    {
        var rows = new List<string[]>();
        var field = new StringBuilder();
        var row = new List<string>();
        bool inQ = false;
        for (int i = 0; i < csv.Length; i++)
        {
            char ch = csv[i];
            if (inQ)
            {
                if (ch == '"')
                {
                    if (i + 1 < csv.Length && csv[i + 1] == '"') { field.Append('"'); i++; }
                    else inQ = false;
                }
                else field.Append(ch);
            }
            else if (ch == '"') inQ = true;
            else if (ch == ',') { row.Add(field.ToString()); field.Clear(); }
            else if (ch == '\n') { row.Add(field.ToString()); rows.Add(row.ToArray()); row.Clear(); field.Clear(); }
            else if (ch != '\r') field.Append(ch);
        }
        if (field.Length > 0 || row.Count > 0) { row.Add(field.ToString()); rows.Add(row.ToArray()); }
        return rows;
    }

    // Verbatim numeric literal — the matched text is emitted as-is (no float
    // round-trip, so "007" and "1e3" pass through unchanged).
    private static readonly Regex Numeric = new Regex(@"^-?(\d+(\.\d+)?|\.\d+)([eE][+-]?\d+)?$", RegexOptions.Compiled);

    public static string EscapeSqlString(string s, string dialect = "standard")
    {
        var outText = s.Replace("'", "''");
        if (dialect == "mysql")
        {
            outText = outText.Replace("\\", "\\\\")
                .Replace("\0", "\\0")
                .Replace("\n", "\\n")
                .Replace("\r", "\\r")
                .Replace("\x1a", "\\Z");
        }
        return outText;
    }

    public static string FieldLiteral(string value, string dialect = "standard", bool inferTypes = true)
    {
        if (inferTypes)
        {
            if (value == "") return "NULL";
            if (Numeric.IsMatch(value)) return value;
        }
        return "'" + EscapeSqlString(value, dialect) + "'";
    }

    public static string SanitizeIdent(string name) =>
        new string(name.Select(c => char.IsLetterOrDigit(c) && c < 128 || c == '_' ? c : '_').ToArray());

    private static string[] SanitizeHeaders(string[] headers)
    {
        var seen = new Dictionary<string, int>();
        var cols = new string[headers.Length];
        for (int i = 0; i < headers.Length; i++)
        {
            string id = SanitizeIdent(headers[i].Trim());
            if (id.Length == 0) id = "col" + (i + 1);
            seen[id] = seen.TryGetValue(id, out int n) ? n + 1 : 1;
            if (seen[id] > 1) id = id + "_" + seen[id];
            cols[i] = id;
        }
        return cols;
    }

    private static string CsvEscape(string field) =>
        field.IndexOfAny(new[] { ',', '"', '\n', '\r' }) < 0
            ? field
            : "\"" + field.Replace("\"", "\"\"") + "\"";

    public sealed class Options
    {
        public string Table = "";
        public string Format = "insert";        // insert | copy | load-data
        public string Dialect = "standard";     // standard | mysql | postgres
        public int BatchSize = 100;
        public bool InferTypes = true;
        public bool QuoteIdentifiers = true;
        public string FileName = "import.csv";
    }

    public sealed class Result
    {
        public bool Ok;
        public string Sql = "";
        public int Rows;
        public string? Error;
    }

    private static string QuoteIdent(string name, bool mysql, bool quoteIds) =>
        !quoteIds ? name : mysql ? $"`{name}`" : $"\"{name}\"";

    public static Result Convert(string csv, Options opts)
    {
        string text = csv.Trim();
        var rows = CsvToRows(text);
        if (text.Length == 0 || rows.Count < 2)
            return new Result { Error = "No rows to import." };

        var cols = SanitizeHeaders(rows[0]);
        var data = rows.Skip(1).ToList();
        bool mysql = opts.Dialect == "mysql";
        bool identMysql = opts.Format == "load-data" ? true
                        : opts.Format == "copy" ? false
                        : mysql;

        string tblRaw = SanitizeIdent(opts.Table);
        string tbl = QuoteIdent(tblRaw.Length == 0 ? "tbl" : tblRaw, identMysql, opts.QuoteIdentifiers);
        string colList = string.Join(", ", cols.Select(c => QuoteIdent(c, identMysql, opts.QuoteIdentifiers)));

        string CellAt(string[] r, int ci) => ci < r.Length ? r[ci] : "";

        if (opts.Format == "copy" || opts.Format == "load-data")
        {
            var payload = new List<string> { string.Join(",", cols.Select(CsvEscape)) };
            payload.AddRange(data.Select(r => string.Join(",", cols.Select((_, ci) => CsvEscape(CellAt(r, ci))))));
            string body = string.Join("\n", payload);
            if (opts.Format == "copy")
            {
                return new Result { Ok = true, Rows = data.Count,
                    Sql = $"COPY {tbl} ({colList}) FROM STDIN WITH (FORMAT csv, HEADER true);\n{body}\n\\." };
            }
            string clean = new string(opts.FileName.Where(c => char.IsLetterOrDigit(c) && c < 128 || c == '.' || c == '_' || c == '-' || c == '/').ToArray());
            if (clean.Length == 0) clean = "import.csv";
            return new Result { Ok = true, Rows = data.Count,
                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" + body };
        }

        int size = Math.Max(1, opts.BatchSize);
        var stmts = new List<string>();
        for (int start = 0; start < data.Count; start += size)
        {
            var values = new List<string>();
            for (int r = start; r < Math.Min(data.Count, start + size); r++)
            {
                var vals = cols.Select((_, ci) => FieldLiteral(CellAt(data[r], ci), opts.Dialect, opts.InferTypes));
                values.Add("  (" + string.Join(", ", vals) + ")");
            }
            stmts.Add($"INSERT INTO {tbl} ({colList}) VALUES\n" + string.Join(",\n", values) + ";");
        }
        return new Result { Ok = true, Sql = string.Join("\n", stmts), Rows = data.Count };
    }
}

// Example:
//   var r = CsvToSql.Convert("id,name\n1,Ada\n2,", new CsvToSql.Options { Table = "users" });
//   Console.WriteLine(r.Sql);
//   // INSERT INTO "users" ("id", "name") VALUES
//   //   (1, 'Ada'),
//   //   (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 →