Artículo técnico

Cursor de filas pull para XLS, XLSX, ODS y CSV en Delphi

HotXLS lee fuentes .xls, .xlsx, .xlsm, .ods, CSV y TSV a través de un único cursor de filas pull, TXLSRowCursor, cuyo FindFirst y FindNext avanzan una fila lógica cada vez mientras solo esa fila permanece en memoria. Una máquina de estados de seis valores separa antes-de-la-primera de EOF, cancelado y con fallo, y el antiguo lector por callbacks es ahora un adaptador sobre el mismo cursor

El escenario le resultará familiar a cualquiera que haya enviado una función de importación. Llega un .xlsx de 200 MB, cableas un manejador OnCell, y el primer requisito después de «léelo» es «detente tras los primeros cientos de asientos revertidos». Ahora la forma de tu código pelea contigo: el bucle vive dentro de la biblioteca, tu manejador tiene que levantar una bandera, cada callback posterior sigue disparándose hasta que el parser se entera, y el estado acumulado — cuántos aciertos hasta ahora, qué columna coincidió, qué hacer después — tiene que vivir en campos de una clase que existe solo para darle al callback un sitio donde sentarse. Nada de eso es un problema de parsing. Es un problema de flujo de control, y es exactamente el que elimina un cursor pull

Lo que cuesta de verdad un callback push a 200 MB

Push invierte el control, y la inversión es justo lo que un llamador que filtra o junta no puede permitirse. Con una API de callbacks la biblioteca posee el bucle, así que el llamador no puede usar Break, no puede intercalar dos fuentes, no puede entregar el lector a una rutina que espera ser dirigida, y no puede expresar «echa un vistazo a la siguiente fila antes de decidir» sin buffering. El coste no es el rendimiento — una ruta de callbacks SAX bien escrita fluye bien — es que cada consumidor no trivial cultiva una pequeña máquina de estados propia para simular el bucle que no le dejaron escribir. Multiplica eso por cuatro formatos de archivo, cada uno históricamente con su propio punto de entrada de escaneo, y las semánticas de filtrado, fórmula y error empiezan a divergir entre ellos, que es precisamente la deriva que HotXLS se propuso cerrar

¿Cómo cambia un cursor pull tu código llamador?

Te devuelve el bucle, y con él el flujo de control Pascal ordinario. TXLSRowCursor.Open acepta un nombre de archivo o un TStream, detecta el formato, carga cadenas compartidas y metadatos de estilo de fecha una vez, y selecciona la hoja 1. SelectSheet (de base 1) o SelectSheetByName re-apunta a otra hoja y restablece el cursor a antes-de-la-primera. FindFirst y FindNext posicionan entonces en la siguiente fila poblada — las filas sin celdas decodificables se saltan, así que RowIndex puede saltar — y la fila actual se expone como CellCount, Cells[] y ValueByCol[], todos de base 1 en el eje de columnas. Salir del bucle es un Break

var
  Cursor: TXLSRowCursor;
  Hits: Integer;
begin
  Cursor := TXLSRowCursor.Create;
  try
    Cursor.FirstRow := 2;        // salta la banda de cabecera
    Cursor.IncludeColumn(1);     // decodifica solo estas dos columnas
    Cursor.IncludeColumn(7);
    if not Cursor.Open('postings-200mb.xlsx') then
      Exit;
    if not Cursor.SelectSheetByName('Ledger') then
      Exit;

    Hits := 0;
    if Cursor.FindFirst then
      repeat
        if VarToStr(Cursor.ValueByCol[7]) = 'REVERSED' then
        begin
          Inc(Hits);
          if Hits = 100 then
            Break;               // Break ordinario; ni bandera de aborto ni centinela
        end;
      until not Cursor.FindNext;
  finally
    Cursor.Free;                 // el destructor termina la pasada
  end;
end;

Proyección y rango se fijan antes de la pasada, no se filtran después. FirstRow, LastRow, IncludeColumn, ClearColumnProjection, IncludeFormulaText, DetectDates y DetectTextTypes se honran todos dentro de los backends, así que una columna no seleccionada nunca asigna su valor, cadena de fórmula o carga de rich-text en primer lugar — la suite de regresión lo prueba con fórmulas de 16 KiB y cadenas en caché que nunca se materializan cuando su columna no está proyectada. Esas opciones se congelan deliberadamente mientras una pasada está activa y vuelven a ser escribibles en EOF, en SelectSheet o tras Close, así que un escaneo nunca puede mezclar dos contratos de decodificación. Si solo necesitas el inventario de hojas y no las filas, la carga de solo metadatos y hojas selectivas es el punto de entrada más barato

Un backend por formato, un bucle de escaneo cada uno

Cada formato tiene exactamente un escáner hacia adelante dentro de HotXLS, y tanto el cursor pull como el lector por callbacks manejan ese mismo escáner. TXLSXForwardRowBackend es la única máquina de estados SAX de hoja de cálculo para las partes de hoja de ECMA-376 Parte 1 §18.3, sosteniendo el lector XML, la tabla de fórmulas compartidas y el parser de rich-text, y avanza a exactamente un límite físico de <row> por llamada. TXLSBiffForwardParser posee los globales, la selección de hoja y el avance de fila para el flujo de registros [MS-XLS]; hacerlo pausable produjo la restricción más aguda de todo el diseño, porque una fórmula de cadena en caché es un registro Formula seguido inmediatamente de un registro String, así que un punto de suspensión por fila nunca debe aterrizar entre los dos. TXLSForwardTextBackend mantiene un lector consciente del BOM, el delimitador activo y un registro lógico — CSV husmea coma, punto y coma, tabulador o barra vertical desde el primer registro ignorando los caracteres entrecomillados, y los campos entrecomillados multilínea se unen con #10 para que el número de fila siga registros lógicos y no saltos de línea físicos. TXLSForwardOdsBackend mantiene una única plantilla de fila física para las tablas §9 de OpenDocument, trata table:number-rows-repeated como un recuento restante en lugar de una expansión, y avanza más allá de las celdas cubiertas sin emitir valores. El lector directo en flujo comparte el mismo cargador de cadenas compartidas y estilos de fecha

El cursor de filas pull de HotXLS despachando a un escáner hacia adelante por formato, un backend SAX para XLSX, un parser de registros para BIFF, un backend de texto que husmea delimitadores y una plantilla de fila ODS, con el lector por callbacks configurado encima como adaptador
Cada formato tiene exactamente un escáner hacia adelante, y tanto el cursor pull como el lector por callbacks manejan ese mismo escáner, así que las semánticas de filtrado y error no pueden divergir

¿Por qué seis estados en lugar de una bandera Eof?

Porque un único booleano vuelve indistinguibles cuatro situaciones distintas, y los llamadores se equivocan con todas. TXLSRowCursorState las nombra explícitamente

  • xrcsClosed — no hay ninguna fuente abierta
  • xrcsBeforeFirst — abierto o re-apuntado, ninguna fila leída aún
  • xrcsActive — de pie sobre una fila válida
  • xrcsEof — la hoja se consumió hasta el final
  • xrcsCancelled — el llamador detuvo la pasada deliberadamente
  • xrcsFaulted — la pasada falló y la excepción original se lanzó

Esa última distinción es la que importa en producción. Una parte de hoja ausente o un inicio de pasada fallido conserva su EReadError y mueve el cursor a xrcsFaulted; nunca se degrada a un False plano que un llamador leería como «esta hoja estaba vacía». Cancel es deliberadamente más estrecho que Close: cierra el backend de la hoja actual y su subflujo de inflado e invalida la fila actual, pero no libera el archivo ZIP ni el flujo fuente, y llamarlo dos veces es un no-op. Tras un cancel retomas llamando a SelectSheet explícitamente — el cursor no reiniciará en silencio una pasada por ti. La propiedad de flujos sigue la misma regla defensiva: xsoBorrowed es el valor por defecto y restaura la posición del flujo al cerrar, xsoOwned transfiere la propiedad solo después de que Open haya tenido éxito, así que una apertura fallida nunca libera un flujo que el llamador aún sostiene

Los seis estados del cursor de filas de HotXLS con las transiciones entre ellos, mostrando Cancel moviendo una pasada activa a cancelada, un inicio de pasada fallido moviéndola a faulted, y cómo ambos se distinguen del fin de hoja
Seis estados nombrados mantienen distinguibles una hoja vacía, una parada deliberada y una pasada fallida, lo que un único booleano Eof no puede hacer
var
  Cursor: TXLSRowCursor;
  Src: TFileStream;
begin
  Src := TFileStream.Create('quarter.ods', fmOpenRead or fmShareDenyWrite);
  try
    Cursor := TXLSRowCursor.Create;
    try
      // xsoBorrowed: el cursor nunca libera Src, y Close restaura la
      // posición que el flujo tenía cuando se llamó a Open
      if not Cursor.Open(Src, xffAuto, xsoBorrowed) then
        Exit;

      if Cursor.FindFirst then
        repeat
          if UserPressedStop then
          begin
            Cursor.Cancel;   // cierra el backend de la hoja y su
            Break;           // subflujo de inflado solamente; idempotente
          end;
        until not Cursor.FindNext;

      case Cursor.State of
        xrcsEof:       Log('sheet consumed to the end');
        xrcsCancelled: Log('stopped by the operator');
        xrcsFaulted:   Log('pass failed; the EReadError was already raised');
      end;
    finally
      Cursor.Free;
    end;
  finally
    Src.Free;                // sigue siendo nuestro, sigue válido, posición restaurada
  end;
end;

Prestar la fila actual sin copiarla

IXLSRowCursorView entrega una fila a otra rutina sin duplicar el array de celdas. La vista guarda una guarda compartida que sostiene el puntero del cursor más un contador de generación UInt64; avanzar, seleccionar una hoja, cancelar, cerrar y destruir el cursor incrementan todos esa generación, y la destrucción además limpia el propietario de la guarda. Así una vista rancia no puede leer memoria liberada: Valid es una sonda sin excepciones que puedes llamar en cualquier momento, mientras que cada otro miembro valida primero y lanza EXLSRowCursorViewInvalidated. Sé honesto con lo que es este contrato — es fail-fast de vida, no una garantía de hilos, y no licencia leer una fila desde un segundo hilo mientras el primero avanza el cursor

var
  View: IXLSRowCursorView;
  Cell: TXLSRowCursorCell;
  I: Integer;
begin
  if Cursor.FindFirst then
    repeat
      View := Cursor.CurrentRowView;      // presta; no se copia ningún array de celdas
      for I := 0 to View.CellCount - 1 do
      begin
        Cell := View.Cells[I];
        if Cell.HasFormula and not Cell.FormulaTextAvailable then
          UseCachedResult(Cell.Value)     // las lecturas forward BIFF conservan el
        else if Cell.Kind = xdkEmpty then //   resultado en caché, no los tokens
          UseStyleOnly(Cell.StyleIndex)   // Blank / MulBlank son celdas reales
        else
          UseValue(Cell.Col, Cell.Value);
      end;
    until not Cursor.FindNext;

  // La interfaz sobrevive al bucle, pero la fila detrás de ella no
  if not View.Valid then    // Valid nunca lanza; Cells[] ahora sí lanzaría
    View := nil;            // EXLSRowCursorViewInvalidated
end;

PeakRowBufferedBytes, y lo que se le permite probar

PeakRowBufferedBytes existe para demostrar que la memoria sigue el ancho de fila y no el recuento de filas. Acumula los registros de celda, Variants, cadenas de fórmula y cargas de rich-text de la fila de salida actual y pliega el conjunto de trabajo específico del formato — el registro lógico CSV, la plantilla de fila física ODS, el pico de registro BIFF o la celda cruda XLSX en decodificación. Léelo junto con SheetPassesStarted, que cuenta cuántas pasadas de hoja empezaron realmente. Dos advertencias mantienen esto honesto: la cifra es una estimación, no una contabilidad exacta del heap, y es monótona desde el Open más reciente, así que es un instrumento de depuración y regresión más que una medida en vivo. Para la panorámica de a dónde van el tiempo y los bytes en libros muy grandes, ver rendimiento de libros grandes en Delphi

Una comparación de HotXLS que muestra una carga de hoja completa manteniendo residente cada fila frente al cursor pull sosteniendo solo la fila actual más un conjunto de trabajo de formato, que es lo que PeakRowBufferedBytes acumula y reporta
PeakRowBufferedBytes acumula la fila de salida actual más el conjunto de trabajo específico del formato, así que la memoria sigue cuán ancha es una fila y no cuántas filas tiene la hoja

El lector push se convirtió en adaptador, y lo que el cursor no hará

TXLSForwardReader ya no lleva puntos de entrada de escaneo separados para XLSX, BIFF y texto. Configura un cursor, lo recorre y traduce la fila actual a eventos OnSheet y OnCell, que es por lo que las dos fachadas ya no pueden divergir en filtrado, estado de fórmula ni manejo de errores. Dos consecuencias conviene conocer antes de actualizar: el SheetIndex de los callbacks ahora es uniformemente de base 1 en TXLSForwardReader (TXLSDirectReader conserva su contrato de eventos de base 0 existente), y OnSheet se dispara antes de SelectSheet, así que fijar SkipSheet significa que la parte de la hoja de cálculo nunca se abre ni se descomprime. Los límites son igual de explícitos: el libro no debe modificarse mientras una pasada está activa, cancelar exige un reinicio explícito, y la ruta forward BIFF nunca descompila tokens de fórmula, así que las celdas de fórmula clásicas reportan HasFormula verdadero con FormulaTextAvailable falso y te entregan el resultado en caché en lugar de inventar una cadena de fórmula vacía. El cursor de filas y su adaptador pasaron 1.298 comprobaciones en Delphi Win32 y Win64 más el paquete estático Win64 de C++Builder 37.0

Si estás sopesando un cursor pull frente al cargador que tienes ahora, la pregunta que hay que hacer no es cuál interpreta más rápido sino cuál te deja escribir la condición de salida que de verdad necesitas. Los detalles completos del componente, las versiones de IDE soportadas y las licencias están en la página del componente de hoja de cálculo HotXLS para Delphi