using System; using System.Collections.Generic; using System.Globalization; using System.IO; namespace Tango.CSV { /// /// CSV reader that supports unknown schemas and typed access with defaults. /// - Case-insensitive column lookup /// - Robust CSV parsing (RFC-4180 style: quotes, commas, escaped quotes) /// - Typed Read with defaults: string, int, double, bool, and enums /// public sealed class CsvDynamicReader { private readonly Dictionary _colIndex; private readonly List _rows; public IReadOnlyList Rows => _rows; public char Delimiter { get; } public static CsvDynamicReader FromString(String csv) { return new CsvDynamicReader(csv); } private CsvDynamicReader(string csvContent) { Delimiter = ','; _colIndex = new Dictionary(StringComparer.OrdinalIgnoreCase); _rows = new List(); using (var sr = new StringReader(csvContent)) { // Read to first non-empty line for headers string headerLine; do { headerLine = sr.ReadLine(); if (headerLine == null) throw new InvalidDataException("CSV file has no header row."); } while (string.IsNullOrWhiteSpace(headerLine)); var headers = ParseCsvLine(headerLine, Delimiter); for (int i = 0; i < headers.Count; i++) { var clean = CleanHeader(headers[i]); if (!_colIndex.ContainsKey(clean)) _colIndex.Add(clean, i); // If duplicate header name appears, first one wins. } // Read all rows string line; while ((line = sr.ReadLine()) != null) { if (line.Length == 0) continue; // skip empty var fields = ParseCsvLine(line, Delimiter).ToArray(); _rows.Add(new Row(this, fields)); } } } public CsvDynamicReader(string path, char delimiter = ',') { if (path == null) throw new ArgumentNullException(nameof(path)); if (!File.Exists(path)) throw new FileNotFoundException("CSV file not found.", path); Delimiter = delimiter; _colIndex = new Dictionary(StringComparer.OrdinalIgnoreCase); _rows = new List(); using (var sr = new StreamReader(path)) { // Read to first non-empty line for headers string headerLine; do { headerLine = sr.ReadLine(); if (headerLine == null) throw new InvalidDataException("CSV file has no header row."); } while (string.IsNullOrWhiteSpace(headerLine)); var headers = ParseCsvLine(headerLine, Delimiter); for (int i = 0; i < headers.Count; i++) { var clean = CleanHeader(headers[i]); if (!_colIndex.ContainsKey(clean)) _colIndex.Add(clean, i); // If duplicate header name appears, first one wins. } // Read all rows string line; while ((line = sr.ReadLine()) != null) { if (line.Length == 0) continue; // skip empty var fields = ParseCsvLine(line, Delimiter).ToArray(); _rows.Add(new Row(this, fields)); } } } internal bool TryGetIndex(string columnName, out int index) { if (columnName == null) { index = -1; return false; } return _colIndex.TryGetValue(columnName.Trim(), out index); } private static string CleanHeader(string s) { if (string.IsNullOrEmpty(s)) return string.Empty; // Trim quotes if header was quoted var t = s.Trim(); if (t.Length >= 2 && t[0] == '"' && t[t.Length - 1] == '"') { t = t.Substring(1, t.Length - 2).Replace("\"\"", "\""); } // Remove BOM if present t = t.Trim('\uFEFF').Trim(); return t; } /// /// RFC-4180-ish CSV line parser: supports quoted fields, commas, escaped quotes (""). /// private static List ParseCsvLine(string line, char delimiter) { var result = new List(); if (line == null) { result.Add(string.Empty); return result; } var sb = new System.Text.StringBuilder(line.Length); bool inQuotes = false; for (int i = 0; i < line.Length; i++) { var c = line[i]; if (inQuotes) { if (c == '"') { // Escaped quote? if (i + 1 < line.Length && line[i + 1] == '"') { sb.Append('"'); i++; // skip next } else { inQuotes = false; } } else { sb.Append(c); } } else { if (c == '"') { inQuotes = true; } else if (c == delimiter) { result.Add(sb.ToString()); sb.Clear(); } else { sb.Append(c); } } } result.Add(sb.ToString()); return result; } // ------- Row -------- public sealed class Row { private readonly CsvDynamicReader _reader; private readonly string[] _values; internal Row(CsvDynamicReader reader, string[] values) { _reader = reader; _values = values ?? new string[0]; } public bool Exists(string columnName) { int idx; return _reader.TryGetIndex(columnName, out idx); } /// /// Typed read with a default fallback. Works for string, int, double, bool, and enums. /// Usage: var v = row.Read("Col", 0); var s = row.Read("Col","def"); var b = row.Read("Flag", false); /// public T Read(string columnName, T defaultValue) { int idx; if (!_reader.TryGetIndex(columnName, out idx)) return defaultValue; var raw = (idx >= 0 && idx < _values.Length) ? _values[idx] : null; return ConvertValue(raw, defaultValue); } // -------- Conversion helpers -------- private static T ConvertValue(string raw, T defaultValue) { if (typeof(T) == typeof(string)) { // For strings return raw as-is (trim optional) object s = raw ?? (object)defaultValue ?? string.Empty; return (T)s; } if (string.IsNullOrWhiteSpace(raw)) return defaultValue; var trimmed = raw.Trim(); // int if (typeof(T) == typeof(int)) { int v; if (int.TryParse(trimmed, NumberStyles.Integer, CultureInfo.InvariantCulture, out v)) return (T)(object)v; return defaultValue; } // double if (typeof(T) == typeof(double)) { double v; if (double.TryParse(trimmed, NumberStyles.Float | NumberStyles.AllowThousands, CultureInfo.InvariantCulture, out v)) return (T)(object)v; return defaultValue; } // bool (accepts true/false, 1/0, yes/no, y/n) if (typeof(T) == typeof(bool)) { bool bv; if (TryParseBool(trimmed, out bv)) return (T)(object)bv; return defaultValue; } // Enums (case-insensitive; also accept underlying numeric) var t = typeof(T); if (t.IsEnum) { try { // Numeric? var underlying = Enum.GetUnderlyingType(t); object numericObj; if (TryParseNumeric(trimmed, underlying, out numericObj)) { var boxed = Enum.ToObject(t, numericObj); return (T)boxed; } T parsed; if (TryParseEnum(trimmed, out parsed)) return parsed; } catch { /* fall through to default */ } return defaultValue; } // Fallback: try ChangeType try { object any = System.Convert.ChangeType(trimmed, typeof(T), CultureInfo.InvariantCulture); return (T)any; } catch { return defaultValue; } } private static bool TryParseBool(string s, out bool value) { // Standard if (bool.TryParse(s, out value)) return true; // Common variants switch (s.Trim().ToLowerInvariant()) { case "1": case "yes": case "y": case "true": case "t": value = true; return true; case "0": case "no": case "n": case "false": case "f": value = false; return true; } value = false; return false; } private static bool TryParseNumeric(string s, Type numericType, out object boxed) { if (numericType == typeof(byte)) { byte v; if (byte.TryParse(s, NumberStyles.Integer, CultureInfo.InvariantCulture, out v)) { boxed = v; return true; } } if (numericType == typeof(sbyte)) { sbyte v; if (sbyte.TryParse(s, NumberStyles.Integer, CultureInfo.InvariantCulture, out v)) { boxed = v; return true; } } if (numericType == typeof(short)) { short v; if (short.TryParse(s, NumberStyles.Integer, CultureInfo.InvariantCulture, out v)) { boxed = v; return true; } } if (numericType == typeof(ushort)) { ushort v; if (ushort.TryParse(s, NumberStyles.Integer, CultureInfo.InvariantCulture, out v)) { boxed = v; return true; } } if (numericType == typeof(int)) { int v; if (int.TryParse(s, NumberStyles.Integer, CultureInfo.InvariantCulture, out v)) { boxed = v; return true; } } if (numericType == typeof(uint)) { uint v; if (uint.TryParse(s, NumberStyles.Integer, CultureInfo.InvariantCulture, out v)) { boxed = v; return true; } } if (numericType == typeof(long)) { long v; if (long.TryParse(s, NumberStyles.Integer, CultureInfo.InvariantCulture, out v)) { boxed = v; return true; } } if (numericType == typeof(ulong)) { ulong v; if (ulong.TryParse(s, NumberStyles.Integer, CultureInfo.InvariantCulture, out v)) { boxed = v; return true; } } boxed = null; return false; } private static bool TryParseEnum(string s, out T value) { try { value = (T)Enum.Parse(typeof(T), s, ignoreCase: true); return true; } catch { value = default(T); return false; } } } } }