Artigo Técnico

Cursor pull de linhas para XLS, XLSX, ODS e CSV em Delphi

O HotXLS lê fontes .xls, .xlsx, .xlsm, .ods, CSV e TSV por meio de um único cursor pull de linhas, o TXLSRowCursor, cujos FindFirst e FindNext avançam uma linha lógica por vez enquanto só essa linha permanece em memória. Uma máquina de estados de seis valores separa before-first de EOF, cancelled e faulted, e o leitor callback mais antigo agora é um adaptador sobre o mesmo cursor

O cenário é familiar para qualquer pessoa que já entregou uma funcionalidade de importação. Chega um .xlsx de 200 MB, você liga um handler OnCell, e o primeiro requisito depois de "ler isso" é "parar após as primeiras cem postagens revertidas". Agora a forma do seu código luta contra você: o loop vive dentro da biblioteca, seu handler precisa levantar uma flag, todo callback subsequente ainda dispara até o parser notar, e o estado acumulado — quantos acertos até agora, qual coluna correspondeu, o que fazer em seguida — precisa viver em fields de uma classe que existe apenas para dar um lugar ao callback. Nada disso é um problema de parsing. É um problema de control flow, e é justamente o que um cursor pull remove

O que um callback push realmente custa a 200 MB

Push inverte o controle, e inversão é exatamente o que um chamador que filtra ou faz join não pode pagar. Com uma API de callback a biblioteca é dona do loop, então o chamador não pode usar Break, não pode intercalar duas fontes, não pode entregar o reader a uma rotina que espera ser dirigida, e não pode expressar "espiar a próxima linha antes de decidir" sem fazer buffer. O custo não é throughput — um caminho de callback SAX bem escrito faz stream tranquilamente — é que todo consumidor não trivial cresce uma pequena máquina de estados própria para simular o loop que não tinha permissão de escrever. Multiplique isso por quatro formatos de arquivo, cada um historicamente com seu próprio ponto de entrada de varredura, e as semânticas de filtragem, fórmula e erro começam a divergir entre eles, que é precisamente a divergência que o HotXLS se propôs a fechar

Como um cursor pull muda seu código de chamada?

Ele devolve o loop a você, e com ele o control flow Pascal comum. TXLSRowCursor.Open aceita um nome de arquivo ou um TStream, detecta o formato, carrega shared strings e metadados de estilo de data uma vez, e seleciona a sheet 1. SelectSheet (base 1) ou SelectSheetByName mira outra worksheet e redefine o cursor para before-first. FindFirst e FindNext então se posicionam na próxima linha povoada — linhas sem células decodificáveis são puladas, então RowIndex pode saltar — e a linha atual é exposta como CellCount, Cells[] e ValueByCol[], todos com base 1 no eixo de colunas. Sair do loop é um Break

var
  Cursor: TXLSRowCursor;
  Hits: Integer;
begin
  Cursor := TXLSRowCursor.Create;
  try
    Cursor.FirstRow := 2;        // pula a faixa de cabeçalho
    Cursor.IncludeColumn(1);     // decodifica só estas duas colunas
    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 comum; sem flag de aborto, sem sentinela
        end;
      until not Cursor.FindNext;
  finally
    Cursor.Free;                 // o destructor encerra o passe
  end;
end;

Projeção e faixa são definidas antes do passe, não filtradas depois. FirstRow, LastRow, IncludeColumn, ClearColumnProjection, IncludeFormulaText, DetectDates e DetectTextTypes são todos honrados dentro dos backends, então uma coluna não selecionada nunca aloca seu valor, string de fórmula ou payload de rich-text em primeiro lugar — a suíte de regressão prova isso com fórmulas de 16 KiB e strings em cache que nunca são materializadas quando sua coluna não é projetada. Essas opções são deliberadamente congeladas enquanto um passe está ativo e voltam a ser graváveis no EOF, em SelectSheet, ou após Close, então uma varredura nunca pode misturar dois contratos de decodificação. Se você só precisa do inventário de sheets em vez das linhas, carregamento apenas de metadados e seletivo de sheets é o ponto de entrada mais barato

Um backend por formato, um loop de varredura cada

Cada formato tem exatamente um scanner forward dentro do HotXLS, e tanto o cursor pull quanto o leitor callback dirigem esse mesmo scanner. TXLSXForwardRowBackend é a única máquina de estados SAX de worksheet para sheet parts da ECMA-376 Part 1 §18.3, guardando o leitor XML, a tabela de shared formulas e o parser de rich-text, e avança até exatamente um limite físico de <row> por chamada. TXLSBiffForwardParser é dono dos globals, da seleção de sheet e do avanço de linha para o stream de records do [MS-XLS]; torná-lo pausável produziu a restrição mais afiada de todo o design, porque uma fórmula com string em cache é um record Formula imediatamente seguido por um record String, então um ponto de suspensão por linha nunca pode cair entre os dois. TXLSForwardTextBackend mantém um leitor consciente de BOM, o delimitador ativo e um record lógico — CSV detecta comma, semicolon, tab ou pipe a partir do primeiro record enquanto ignora caracteres entre aspas, e campos entre aspas com múltiplas linhas são unidos com #10 para que o número da linha acompanhe records lógicos em vez de newlines físicas. TXLSForwardOdsBackend mantém um único template de linha física para tabelas do OpenDocument §9, trata table:number-rows-repeated como uma contagem restante em vez de uma expansão, e avança além das células cobertas sem emitir valores. O streaming direct reader compartilha o mesmo carregador de shared strings e estilos de data

O cursor pull de linhas do HotXLS despachando para um scanner forward por formato, um backend SAX para XLSX, um parser de records para BIFF, um backend de texto que detecta delimitador e um template de linha ODS, com o leitor callback configurado por cima como um adaptador
Cada formato tem exatamente um scanner forward, e tanto o cursor pull quanto o leitor callback dirigem esse mesmo scanner, então as semânticas de filtragem e erro não podem divergir

Por que seis estados em vez de uma flag Eof?

Porque um único booleano torna quatro situações diferentes indistinguíveis, e os chamadores erram o palpite sobre todas. TXLSRowCursorState as nomeia explicitamente

  • xrcsClosed — nenhuma fonte está aberta
  • xrcsBeforeFirst — aberto ou re-mirado, nenhuma linha lida ainda
  • xrcsActive — parado sobre uma linha válida
  • xrcsEof — a sheet foi consumida até o fim
  • xrcsCancelled — o chamador parou o passe deliberadamente
  • xrcsFaulted — o passe falhou e a exceção original foi levantada

A última distinção é a que importa em produção. Uma worksheet part ausente ou um início de passe que falha mantém seu EReadError e move o cursor para xrcsFaulted; nunca é rebaixado a um False simples que o chamador leria como "esta sheet estava vazia". Cancel é deliberadamente mais estreito que Close: ele fecha o backend da worksheet atual e seu substream de inflate e invalida a linha atual, mas não libera o arquivo ZIP nem o stream de origem, e chamá-lo duas vezes é um no-op. Depois de um cancel você retoma chamando SelectSheet explicitamente — o cursor não reiniciará um passe silenciosamente por você. A posse do stream segue a mesma regra defensiva: xsoBorrowed é o padrão e restaura a posição do stream ao fechar, xsoOwned transfere a posse somente depois que Open já teve sucesso, então um open que falha nunca libera um stream que o chamador ainda mantém

Os seis estados do cursor de linhas do HotXLS com as transições entre eles, mostrando Cancel movendo um passe ativo para cancelled, um início de passe que falha movendo-o para faulted, e como ambos permanecem distintos do fim da sheet
Seis estados nomeados mantêm uma sheet vazia, uma parada deliberada e um passe que falhou distinguishable, o que um único booleano Eof não consegue fazer
var
  Cursor: TXLSRowCursor;
  Src: TFileStream;
begin
  Src := TFileStream.Create('quarter.ods', fmOpenRead or fmShareDenyWrite);
  try
    Cursor := TXLSRowCursor.Create;
    try
      // xsoBorrowed: o cursor nunca libera Src, e o Close restaura a
      // posição que o stream tinha quando Open foi chamado
      if not Cursor.Open(Src, xffAuto, xsoBorrowed) then
        Exit;

      if Cursor.FindFirst then
        repeat
          if UserPressedStop then
          begin
            Cursor.Cancel;   // fecha só o backend da worksheet e seu
            Break;           // substream de inflate; 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;                // ainda nosso, ainda válido, posição restaurada
  end;
end;

Emprestando a linha atual sem copiá-la

O IXLSRowCursorView entrega uma linha a outra rotina sem duplicar o array de células. A view armazena um guard compartilhado contendo o ponteiro do cursor mais um contador de geração UInt64; avançar, selecionar uma sheet, cancelar, fechar e destruir o cursor todos incrementam essa geração, e a destruição adicionalmente limpa o dono do guard. Assim uma view obsoleta não pode ler memória liberada: Valid é uma sonda sem exceção que você pode chamar a qualquer momento, enquanto todo outro membro valida primeiro e levanta EXLSRowCursorViewInvalidated. Seja honesto quanto ao que este contrato é — é fail-fast de lifetime, não uma garantia de thread-safety, e ele não licencia ler uma linha de uma segunda thread enquanto a primeira avança o cursor

var
  View: IXLSRowCursorView;
  Cell: TXLSRowCursorCell;
  I: Integer;
begin
  if Cursor.FindFirst then
    repeat
      View := Cursor.CurrentRowView;      // empresta; nenhum array de células é copiado
      for I := 0 to View.CellCount - 1 do
      begin
        Cell := View.Cells[I];
        if Cell.HasFormula and not Cell.FormulaTextAvailable then
          UseCachedResult(Cell.Value)     // leituras BIFF forward mantêm o
        else if Cell.Kind = xdkEmpty then //   resultado em cache, não os tokens
          UseStyleOnly(Cell.StyleIndex)   // Blank / MulBlank são células reais
        else
          UseValue(Cell.Col, Cell.Value);
      end;
    until not Cursor.FindNext;

  // A interface sobrevive ao loop, mas a linha por trás dela não
  if not View.Valid then    // Valid nunca levanta; Cells[] agora levantaria
    View := nil;            // EXLSRowCursorViewInvalidated
end;

PeakRowBufferedBytes, e o que ele tem permissão de provar

O PeakRowBufferedBytes existe para demonstrar que a memória acompanha a largura da linha em vez da contagem de linhas. Ele acumula os records de célula, Variants, strings de fórmula e payloads de rich-text da linha de saída atual e incorpora o working set específico do formato — o record lógico do CSV, o template de linha física do ODS, o pico de records do BIFF, ou a célula bruta do XLSX em decodificação no momento. Leia-o junto com SheetPassesStarted, que conta quantos passes de worksheet realmente começaram. Duas ressalvas mantêm isso honesto: o número é uma estimativa, não uma contabilidade exata de heap, e ele é monotônico desde o Open mais recente, então é um instrumento de depuração e regressão em vez de um medidor ao vivo. Para o quadro mais amplo de para onde vão tempo e bytes em livros muito grandes, veja performance de pastas de trabalho grandes em Delphi

Uma comparação do HotXLS mostrando um load de sheet inteira mantendo cada linha residente contra o cursor pull segurando apenas a linha atual mais um working set de formato, que é o que o PeakRowBufferedBytes acumula e relata
O PeakRowBufferedBytes acumula a linha de saída atual mais o working set específico do formato, então a memória acompanha quão larga uma linha é em vez de quantas linhas a sheet tem

O leitor push virou um adaptador, e o que o cursor não fará

O TXLSForwardReader não carrega mais pontos de entrada de varredura separados para XLSX, BIFF e texto. Ele configura um cursor, o percorre, e traduz a linha atual em eventos OnSheet e OnCell, que é por que as duas fachadas não podem mais divergir em filtragem, estado de fórmula ou tratamento de erro. Duas consequências valem conhecer antes de atualizar: o SheetIndex do callback agora é uniformemente base 1 no TXLSForwardReader (o TXLSDirectReader mantém seu contrato de eventos base 0 existente), e OnSheet dispara antes de SelectSheet, então definir SkipSheet significa que a worksheet part nunca é aberta nem descomprimida. Os limites são igualmente explícitos: a pasta de trabalho não deve ser modificada enquanto um passe está ativo, cancelar exige um reinício explícito, e o caminho BIFF forward nunca decompila tokens de fórmula, então células de fórmula clássica relatam HasFormula true com FormulaTextAvailable false e entregam a você o resultado em cache em vez de inventar uma string de fórmula vazia. O cursor de linhas e seu adaptador passaram em 1.298 verificações no Delphi Win32 e Win64 mais o pacote estático Win64 do C++Builder 37.0

Se você está ponderando um cursor pull contra o loader que tem agora, a pergunta a fazer não é qual analisa mais rápido, mas qual permite escrever a condição de saída de que você realmente precisa. Detalhes completos do componente, versões de IDE suportadas e licenciamento estão na página do componente de planilha Delphi HotXLS