Opciones de análisis CSV con .NET

Estoy buscando en mi archivo delimitado (por ejemplo, CSV, separador de tabs, etc.) las opciones de análisis basadas en MS stack en general, y .net específicamente. La única tecnología que excluyo es SSIS, porque ya sé que no satisfará mis necesidades.

Entonces mis opciones parecen ser:

  1. Regex.Split
  2. TextFieldParser
  3. Analizador OLV CSV

Tengo dos criterios que debo cumplir. Primero, dado el siguiente archivo que contiene dos filas de datos lógicos (y cinco filas físicas en total):

101, Bob, "Keeps his house ""clean"".
Needs to work on laundry."
102, Amy, "Brilliant.
Driven.
Diligent."

Los resultados analizados deben producir dos “filas” lógicas, que constan de tres cadenas (o columnas) cada una. ¡La tercera cadena de fila / columna debe preservar las nuevas líneas! Dicho de otra manera, el analizador debe reconocer cuándo las líneas “continúan” en la siguiente fila física, debido al calificador de texto “no cerrado”.

El segundo criterio es que el delimitador y el calificador de texto deben ser configurables, por archivo. Aquí hay dos cadenas, tomadas de diferentes archivos, que debo ser capaz de analizar:

 var first = @"""This"",""Is,A,Record"",""That """"Cannot"""", they say,"","""",,""be"",rightly,""parsed"",at all"; var second = @"~This~|~Is|A|Record~|~ThatCannot~|~be~|~parsed~|at all"; 

Un análisis correcto de la cadena “primero” sería:

  • Esta
  • Es, A, Grabar
  • Ese “No se puede”, dicen,
  • _
  • _
  • ser
  • correctamente
  • analizado
  • en absoluto

El ‘_’ simplemente significa que se capturó un espacio en blanco; no quiero que aparezca una barra inferior literal.

Se puede hacer una suposición importante sobre los archivos planos que se analizarán: habrá un número fijo de columnas por archivo.

Ahora para sumergirse en las opciones técnicas.

REGEX

En primer lugar, muchos encuestados comentan que regex “no es la mejor manera” de lograr el objective. Sin embargo, encontré un comentarista que me ofreció una excelente expresión regular de CSV :

 var regex = @",(?=(?:[^""]*""[^""]*"")*(?![^""]*""))"; var Regex.Split(first, regex).Dump(); 

Los resultados, aplicados a la cadena “primero”, son bastante maravillosos:

  • “Esta”
  • “Es, A, Registro”
  • “Eso” “No se puede” “, dicen”
  • “”
  • _
  • “ser”
  • correctamente
  • “analizado”
  • en absoluto

Sería bueno si las citas se limpiaran, pero puedo lidiar fácilmente con eso como un paso posterior al proceso. De lo contrario, este enfoque se puede utilizar para analizar ambas cadenas de muestras “primero” y “segundo”, siempre que la expresión regular se modifique para símbolos de tilde y de tubería en consecuencia. ¡Excelente!

Pero el problema real se refiere a los criterios de varias líneas. Antes de que se pueda aplicar una expresión regular a una cadena, debo leer la “fila” lógica completa del archivo. Lamentablemente, no sé cuántas filas físicas leer para completar la fila lógica, a menos que tenga una máquina de expresiones regulares / estado.

Entonces esto se convierte en un problema de “pollo y huevo”. Mi mejor opción sería leer todo el archivo en la memoria como una cadena gigante y dejar que la expresión regular clasifique las líneas múltiples (no revisé si la expresión regular anterior podía manejar eso). Si tengo un archivo de 10 gigas, esto podría ser un poco precario.

En la siguiente opción.

TextFieldParser

Tres líneas de código harán aparente el problema con esta opción:

 var reader = new Microsoft.VisualBasic.FileIO.TextFieldParser(stream); reader.Delimiters = new string[] { @"|" }; reader.HasFieldsEnclosedInQuotes = true; 

La configuración de Delimiters se ve bien. Sin embargo, el “HasFieldsEnclosedInQuotes” es “se acabó el juego”. Me sorprende que los delimitadores sean arbitrariamente configurables, pero en cambio no tengo otra opción de calificador que no sean las citas. Recuerde, necesito configurabilidad sobre el calificador de texto. Entonces, de nuevo, a menos que alguien conozca un truco de configuración de TextFieldParser, se acabó el juego.

OLEDB

Un colega me dice que esta opción tiene dos fallas importantes. En primer lugar, tiene un rendimiento terrible para archivos grandes (por ejemplo, 10 gigas). En segundo lugar, me dicen, adivina los tipos de datos de datos de entrada en lugar de permitirte especificarlos. No está bien.

AYUDA

Así que me gustaría saber los hechos que obtuve mal (si corresponde), y las otras opciones que me perdí. Tal vez alguien sepa una forma de jurado de TextFieldParser para usar un delimitador arbitrario. Y tal vez OLEDB ha resuelto los problemas establecidos (¿o quizás nunca los tuvo?).

¿Que dices tu?

¿Intentó buscar un analizador CSV .NET ya existente? Éste afirma manejar registros de varias líneas significativamente más rápido que OLEDB.

Hace un tiempo escribí esto como un analizador de CSV liviano e independiente. Creo que cumple con todos sus requisitos. Pruébelo sabiendo que probablemente no sea a prueba de balas.

Si funciona para usted, siéntase libre de cambiar el espacio de nombres y utilizar sin restricciones.

 namespace NFC.Portability { using System; using System.Collections.Generic; using System.Data; using System.IO; using System.Linq; using System.Text; ///  /// Loads and reads a file with comma-separated values into a tabular format. ///  ///  /// Parsing assumes that the first line will always contain headers and that values will be double-quoted to escape double quotes and commas. ///  public unsafe class CsvReader { private const char SEGMENT_DELIMITER = ','; private const char DOUBLE_QUOTE = '"'; private const char CARRIAGE_RETURN = '\r'; private const char NEW_LINE = '\n'; private DataTable _table = new DataTable(); ///  /// Gets the data contained by the instance in a tabular format. ///  public DataTable Table { get { // validation logic could be added here to ensure that the object isn't in an invalid state return _table; } } ///  /// Creates a new instance of CsvReader. ///  /// The fully-qualified path to the file from which the instance will be populated. public CsvReader( string path ) { if( path == null ) { throw new ArgumentNullException( "path" ); } FileStream fs = new FileStream( path, FileMode.Open ); Read( fs ); } ///  /// Creates a new instance of CsvReader. ///  /// The stream from which the instance will be populated. public CsvReader( Stream stream ) { if( stream == null ) { throw new ArgumentNullException( "stream" ); } Read( stream ); } ///  /// Creates a new instance of CsvReader. ///  /// The array of bytes from which the instance will be populated. public CsvReader( byte[] bytes ) { if( bytes == null ) { throw new ArgumentNullException( "bytes" ); } MemoryStream ms = new MemoryStream(); ms.Write( bytes, 0, bytes.Length ); ms.Position = 0; Read( ms ); } private void Read( Stream s ) { string lines; using( StreamReader sr = new StreamReader( s ) ) { lines = sr.ReadToEnd(); } if( string.IsNullOrWhiteSpace( lines ) ) { throw new InvalidOperationException( "Data source cannot be empty." ); } bool inQuotes = false; int lineNumber = 0; StringBuilder buffer = new StringBuilder( 128 ); List values = new List(); Action endSegment = () => { values.Add( buffer.ToString() ); buffer.Clear(); }; Action endLine = () => { if( lineNumber == 0 ) { CreateColumns( values ); values.Clear(); } else { CreateRow( values ); values.Clear(); } values.Clear(); lineNumber++; }; fixed( char* pStart = lines ) { char* pChar = pStart; char* pEnd = pStart + lines.Length; while( pChar < pEnd ) // leave null terminator out { if( *pChar == DOUBLE_QUOTE ) { if( inQuotes ) { if( Peek( pChar, pEnd ) == SEGMENT_DELIMITER ) { endSegment(); pChar++; } else if( !ApproachingNewLine( pChar, pEnd ) ) { buffer.Append( DOUBLE_QUOTE ); } } inQuotes = !inQuotes; } else if( *pChar == SEGMENT_DELIMITER ) { if( !inQuotes ) { endSegment(); } else { buffer.Append( SEGMENT_DELIMITER ); } } else if( AtNewLine( pChar, pEnd ) ) { if( !inQuotes ) { endSegment(); endLine(); //pChar++; } else { buffer.Append( *pChar ); } } else { buffer.Append( *pChar ); } pChar++; } } // append trailing values at the end of the file if( values.Count > 0 ) { endSegment(); endLine(); } } ///  /// Returns the next character in the sequence but does not advance the pointer. Checks bounds. ///  /// Pointer to current character. /// End of range to check. ///  /// Returns the next character in the sequence, or char.MinValue if range is exceeded. ///  private char Peek( char* pChar, char* pEnd ) { if( pChar < pEnd ) { return *( pChar + 1 ); } return char.MinValue; } ///  /// Determines if the current character represents a newline. This includes lookahead for two character newline delimiters. ///  ///  ///  ///  private bool AtNewLine( char* pChar, char* pEnd ) { if( *pChar == NEW_LINE ) { return true; } if( *pChar == CARRIAGE_RETURN && Peek( pChar, pEnd ) == NEW_LINE ) { return true; } return false; } ///  /// Determines if the next character represents a newline, or the start of a newline. ///  ///  ///  ///  private bool ApproachingNewLine( char* pChar, char* pEnd ) { if( Peek( pChar, pEnd ) == CARRIAGE_RETURN || Peek( pChar, pEnd ) == NEW_LINE ) { // technically this cheats a little to avoid a two char peek by only checking for a carriage return or new line, not both in sequence return true; } return false; } private void CreateColumns( List columns ) { foreach( string column in columns ) { DataColumn dc = new DataColumn( column ); _table.Columns.Add( dc ); } } private void CreateRow( List values ) { if( values.Where( (o) => !string.IsNullOrWhiteSpace( o ) ).Count() == 0 ) { return; // ignore rows which have no content } DataRow dr = _table.NewRow(); _table.Rows.Add( dr ); for( int i = 0; i < values.Count; i++ ) { dr[i] = values[i]; } } } } 

Eche un vistazo al código que publiqué a esta pregunta:

https://stackoverflow.com/a/1544743/3043

Cubre la mayoría de sus requisitos y no sería necesario actualizarlo para admitir delimitadores alternativos o calificadores de texto.