Skip to content

Excel ​

Il connettore Excel legge e scrive workbook .xlsx e .xlsm, e modifica quelli che qualcuno ha già rifinito a mano senza perdere ciò che non ha scritto. Subpath @noeva/etl-core/connectors/excel, tre nodi:

NodoChe cosa fa
excelSourceLegge uno o più fogli, a flusso, con le colonne abbinate per titolo.
excelSinkCrea e aggiorna workbook: append, upsert per chiave, replace; fogli nuovi, colonne nuove, formati, formule.
excelTaskOperazioni strutturali senza record su fogli e colonne (riordina, rinomina, cancella, inserisci), con i riferimenti.

Più la transform excelValidate, gli stessi controlli del sink record per record. Lavora solo con lo storage filesystem (vedi «Che cosa non si copre»). Decisioni: ADR-0110 (il motore), ADR-0111 (modalità e retrySafe), ADR-0112 (scrittura atomica).

Vocabolario: workbook (il file), foglio (una tabella con nome), riga d'intestazione (la riga che dà un titolo a ogni colonna). Vedi CONTEXT.md, sezione «File Excel».

excelSource ​

ts
import { filesystemStorage } from "@noeva/etl-core/connectors/file";
import { excelSource } from "@noeva/etl-core/connectors/excel";

const source = excelSource(filesystemStorage({ baseDir: "/dati" }), {
  path: "ordini.xlsx",
  sheet: "Ordini",
  columns: ["Cliente", "Importo", "Data"],
});

Lo storage è quello del connettore file: il source riceve i byte da lì (legge da qualunque storage del connettore file; è il sink a volere il filesystem).

OpzioneSignificato
pathIl workbook da leggere. Serve path oppure prefix, non entrambi.
prefixLegge in sequenza tutti i .xlsx/.xlsm sotto il prefisso (i lock ~$… si saltano).
sheetIl foglio, per nome, senza distinguere maiuscole e minuscole. Serve sheet oppure sheets.
sheetsPiù fogli: l'elenco dei nomi o "all" (tutti, nell'ordine del workbook). Ogni foglio ha la sua intestazione.
sheetFieldIl campo che in ogni record dà il nome del foglio d'origine, come lo scrive il workbook. Obbligatorio con più fogli (elenco di più nomi o "all").
headerRowLa riga d'intestazione, da 1 (default 1). Le righe sopra non sono dati.
columnsI titoli da leggere: il record contiene solo quelli, abbinati per titolo e non per posizione.
onMissingSheeterror (default) ferma la run; skip salta quel workbook (scelta esplicita, con un warning nel log).
instanceIdId dell'istanza storage, mostrato nel descriptor.

Cosa esce ​

  • Cella vuota → null. Riga del tutto vuota → saltata.
  • Formula → il valore che Excel ha salvato nel file. Senza valore in cache, o se è un errore di Excel (#DIV/0!, #N/A), vale null; gli errori si contano in <nome>:excel.errori-di-formula su ctx.metrics e non fanno fallire la run.
  • Date → Date in UTC; la base 1900 o 1904 la legge il file.
  • Celle unite → il valore sta solo nella cella master, le altre sono null.
  • Testo ricco e collegamenti → il loro testo.

Errori ​

Ogni errore nomina file, foglio e, dove ha senso, riga e colonna (riga 1, colonna E): foglio inesistente (con l'elenco di quelli che il workbook ha), riga d'intestazione assente o vuota, titolo duplicato, titolo vuoto dentro l'intestazione (solo senza columns), colonna dichiarata assente. Con columns contano solo i titoli dichiarati: un'intestazione con altre colonne vuote o ripetute non dà errore finché non le riguardano.

Memoria ​

Il foglio si legge a flusso: lo zip si apre con accesso casuale (JSZip), si leggono prima le stringhe condivise e gli stili, poi il foglio passa dal parser SAX un pezzo alla volta e ogni riga esce appena si chiude. In memoria stanno il file compresso (lo storage lo dà a pezzi, ma lo zip ha bisogno dell'accesso casuale), le stringhe condivise e una riga alla volta; il foglio decompresso no. Un workbook con milioni di stringhe condivise le paga tutte, perché il foglio le referenzia per indice.

Perché non il lettore a flusso di exceljs: sui file salvati da Excel lo zip mette i fogli prima delle stringhe condivise, e quel lettore, a seconda dei tempi del flusso, a volte non emette il foglio (osservato fra lo 0 e il 6% delle letture sulle fixture Prova.xlsx). Con l'accesso casuale l'ordine delle voci nello zip non conta, e il test lettura.test.ts legge le fixture 100 volte verificando che il risultato non cambi.

Il parser XML è saxes (licenza ISC, circa 100 kB con la sua unica dipendenza xmlchars): è un parser SAX che accetta i dati a pezzi, come serve qui, e fast-xml-parser no. Era già nell'albero come dipendenza di exceljs, quindi dichiararlo diretto non aggiunge pacchetti installati.

Misura (memoria.test.ts, workbook generato nel test, 200.000 righe e 3 colonne, file di 4,3 MB; Node 24 al momento della misura, picco di heapUsed oltre la base): circa 12 MB a flusso (fino a 28 MB nelle misure con più rumore del garbage collector) contro circa 360 MB del caricamento intero con exceljs, che è come leggeva la versione precedente. Il test fallisce se il flusso supera un quarto del caricamento intero.

Il codec xlsx del connettore file e il nodo parse restano invariati.

Descriptor ​

kind: "excel", con storage, path o prefix, foglio e riga d'intestazione. Nessun segreto. Con columns il nodo dichiara le colonne (tipo unknown) per il DAG.

excelSink ​

ts
import { filesystemStorage } from "@noeva/etl-core/connectors/file";
import { excelSink } from "@noeva/etl-core/connectors/excel";

const sink = excelSink(filesystemStorage({ baseDir: "/dati" }), {
  mode: "append",
  path: "note-spese.xlsx",
  sheet: "Mario",
  columns: [
    { field: "descrizione", title: "Descrizione" },
    { field: "importo" },
  ],
});

Se il workbook non esiste lo crea: lo scheletro minimo del pacchetto (tipi di contenuto, relazioni, workbook.xml, styles.xml) e un foglio con la riga d'intestazione e una riga per record, nell'ordine in cui arrivano. Se esiste (salvato da Excel), aggiunge le righe in fondo al foglio e basta, come descrive la sezione seguente. In entrambi i casi il file scritto si rilegge con excelSource (stesso columns per titolo e stessa headerRow).

OpzioneSignificato
mode"append", "upsert" o "replace" (vedi sotto). Le opzioni di una modalità sono vietate dal tipo nelle altre: key, cancellaQuando e cancellaRighe esistono solo in upsert.
destructivetrue obbligatorio con replace e con cancellaQuando: senza, ConfigError alla costruzione. Compare nel descriptor. Vedi «Operazioni distruttive».
pathIl workbook, percorso fisso.
sheetIl foglio: un nome fisso oppure { field }, il campo del record che lo dà (vedi «Foglio nuovo e colonne nuove»). Un nome fisso non valido (vuoto, oltre 31 caratteri, / \ ? * : [ ], apostrofo ai bordi, «History») → ConfigError alla costruzione, mai normalizzato.
columns{ field, title?, format?, width?, formula? } nell'ordine delle colonne; il titolo è il campo se manca. Senza columns, i campi dei record nell'ordine in cui si incontrano. Un campo non dichiarato è un errore.
repeatFormulastrue (default): nelle righe nuove le colonne senza formula ripetono, traslata, la formula della riga precedente. Vedi «Append su un workbook esistente».
onMissingSheetIl foglio che manca in un workbook esistente: create (default) lo aggiunge, error ferma la run.
headerRowLa riga d'intestazione, da 1 (default 1). In un workbook nuovo le righe sopra restano vuote; in uno esistente restano come sono.
onMissingFilecreate (default) crea il workbook; error ferma la run se manca.
templateIl file modello (.xlsx o .xlsm, nello stesso storage) che si copia al posto dello scheletro minimo quando il workbook manca. Stessa estensione di path, altrimenti ConfigError. Vedi «Modelli e file .xlsm».
sheetTemplateIl foglio del workbook che si clona per ogni foglio nuovo, con la sua tabella a nome rigenerato. Errore se ha grafici, pivot o disegni. Vedi «Modelli e file .xlsm».
instanceIdId dell'istanza storage, mostrato nel descriptor.
nameNome del nodo, per il report e le metriche (default <storage>:excel).
onFileApertoInExcelerror (default) blocca la scrittura se esiste ~$nome.xlsx; ignore lo ignora.
maxSheetBytesTetto in byte decompressi del foglio da riscrivere, default 64 MiB.

Foglio nuovo e colonne nuove ​

  • Il foglio sceglie il record. sheet: { field: "utente" } smista i record, un foglio per valore del campo. «Mario» e «mario» sono lo stesso foglio (come per Excel) e si scrive con l'ortografia di quello che c'è; se non c'è, con quella del primo record. Il campo non è una colonna, a meno che non lo dichiari in columns. Un valore che non è un testo o non è un nome di foglio valido ferma il batch prima di toccare il file (il messaggio dice record e motivo, mai il valore).
  • Foglio nuovo. Con onMissingSheet: "create" (default) il foglio che manca si aggiunge in fondo: la parte del foglio, la voce in workbook.xml, la relazione e il tipo di contenuto. Gli altri fogli e ogni altra parte restano identici (hash). Le stringhe sono inlineStr e gli elementi stanno nell'ordine dello schema. Le date di una colonna senza format usano il primo stile di data che il workbook ha già; se non ne ha nessuno il sink ne aggiunge uno (yyyy-mm-dd hh:mm:ss), così la data si rilegge come data. fullCalcOnLoad resta acceso se ci sono formule.
  • Colonna nuova. Un campo dichiarato che il foglio non ha diventa una colonna in fondo, col suo titolo in intestazione. Accanto a una tabella Excel, se la colonna è libera, la tabella si estende (ref, filtro automatico e tableColumn col nome nuovo, con lo stile dell'ultima intestazione); altrimenti va dopo l'ultima colonna occupata del foglio. Le colonne sconosciute del file restano intatte, e le colonne si riconoscono per titolo: un foglio riordinato a mano funziona.
  • Campo assente / null. Un campo assente dal record non scrive nulla; null lascia la cella vuota con lo stile della colonna (le righe nuove copiano lo stile della precedente).

Formati e stili di ciò che il sink crea ​

Il sink dichiara come appare ciò che crea (un workbook nuovo, un foglio nuovo, una colonna aggiunta). Su un foglio e su una colonna che il file ha già decide l'utente: il sink non li tocca.

OpzioneDoveSignificato
columns[].formatcolonna"text", "number" (#,##0.00), "integer", "currency" (#,##0.00 "€"), "percent" (0.00%), "date", "datetime" o { code: "0.000" }
columns[].widthcolonnaLarghezza in caratteri, da 0 (escluso) a 255. Solo nei fogli creati.
columns[].formulacolonnaUna formula al posto del valore, scritta nelle righe nuove ({row}, {prev}). Vedi «Append su un workbook esistente».
boldHeaderfoglioIntestazione in grassetto.
freezeHeaderfoglioBlocca le righe fino all'intestazione (headerRow) compresa.
autoFilterfoglioFiltro automatico su intestazione e righe; con table sono i pulsanti di filtro della tabella (che senza non li ha).
table: "Nome"foglioUna tabella Excel su intestazione e righe. Un secondo append la estende come qualunque tabella.
  • Stili solo in aggiunta. styles.xml cambia soltanto con elementi in coda (numFmt, un font in grassetto, gli xf di cellXfs): gli indici esistenti non si spostano, i formati già presenti con lo stesso codice si riusano, e un test confronta gli xf di prima con quelli di dopo. Se non serve alcuno stile la parte resta identica byte per byte.
  • Colonne già presenti. Un format dichiarato su una colonna che il foglio ha già non la cambia; le righe nuove copiano lo stile della precedente. Una colonna aggiunta a un foglio esistente riceve il suo format (non width, che richiederebbe di riscrivere i cols dell'utente).
  • Date. Una Date in una colonna con format ha quel formato; senza, il primo stile di data del workbook (o uno aggiunto): si rilegge sempre come Date.
  • table. Il nome è valido (lettere, cifre, _ e .; senza spazi; non R, C né un riferimento di cella come A1) e unico nel workbook, senza badare alle maiuscole e contando i nomi definiti; altrimenti ConfigError (nome non valido) o rifiuto senza toccare il file (nome già usato). Non si combina con sheet: { field }. I titoli delle colonne devono essere unici senza badare alle maiuscole e senza a capo. Il filtro vive dentro la tabella: il foglio non ha un autoFilter suo, perché Excel ripara una tabella sovrapposta a un filtro di foglio.
  • Ordine dello schema. sheetViews (blocco), cols, sheetData, autoFilter, pageMargins, tableParts stanno nell'ordine di ORDINE_ELEMENTI_FOGLIO; la parte tabella, le relazioni del foglio e il tipo di contenuto si aggiungono senza toccare quelle esistenti. L'apertura in Excel vero resta nella verifica manuale finale.

Che cosa vuol dire «completo» ​

Il connettore non crea tutto ciò che un file Excel può contenere; «completo» ha tre livelli:

  1. Preservare. Ciò che il file ha già e il connettore non sa creare (grafici, pivot, macro, forme, sparkline, commenti, convalide, formattazione condizionale) resta identico byte per byte.
  2. Dichiarare. Ciò che il connettore crea da configurazione: workbook, fogli, colonne, formati e larghezze, intestazione in grassetto, blocco, filtro, tabella (questa sezione).
  3. Fornire da un modello. Grafici, pivot, sparkline, forme e macro non si dichiarano: si portano in un file modello (template, sheetTemplate): vedi «Modelli e file .xlsm».

Modelli e file .xlsm ​

Ciò che il connettore non sa creare da configurazione (grafici, pivot, sparkline, forme, macro) arriva da un file modello salvato da Excel.

ts
excelSink(storage, {
  mode: "append",
  path: "report-2026.xlsm",
  template: "modelli/report.xlsm", // copiato se report-2026.xlsm manca
  sheet: { field: "utente" },
  sheetTemplate: "Modello", // un foglio del modello, clonato per ogni utente
});
  • template. Se il workbook manca (e onMissingFile è create) si copia il modello, byte per byte, e si scrive nei suoi fogli: grafici, pivot, macro, stili e commenti arrivano con lui. Il modello sta nello stesso storage di path; se manca è un errore e il file non si crea. Se il workbook esiste il modello non si legge. Le righe che il modello ha già nel foglio restano: un modello utile ha l'intestazione e al massimo una riga di formule.
  • sheetTemplate. Il foglio che manca si crea clonando un foglio del workbook (di solito arrivato dal template) invece di uno scheletro vuoto: celle, stili, larghezze, intestazione e formule restano quelli del modello, poi le righe si aggiungono in fondo come in un foglio esistente (colonne per titolo, tabella estesa). Ogni tabella Excel del modello diventa una tabella nuova con id, name e displayName rigenerati e unici (tblVendite → tblVendite_2, _3…; i riferimenti strutturati nelle formule seguono il nome). Il foglio clonato non è selezionato, non porta il codeName del modello (in un .xlsm deve essere unico) e ha GUID nuovi. Il modello resta nel workbook. In un foglio clonato table e width non si applicano (la tabella e le larghezze sono quelle del modello); i format delle colonne aggiunte sì. Non si clonano i nomi definiti locali al foglio (definedName con localSheetId).
  • Il modello non deve avere grafici, pivot, disegni, commenti, sparkline, controlli né impostazioni di stampa collegate (printerSettings): il layer non sa duplicarli e la run si ferma con un LoadError che li elenca, senza toccare il file. Un foglio per i grafici e uno per i dati è il modo di tenerli separati: si clona il secondo.
  • .xlsm. Il sink scrive conservando vbaProject.bin byte per byte (un test lo prova sulla fixture creata da Excel). Estensione e contenuto devono coincidere: un .xlsm che in realtà è un .xlsx (o il contrario) ferma la run, perché Excel non lo apre. Il layer non crea macro, quindi un .xlsm nuovo nasce solo da un template .xlsm. Il source legge un .xlsm in streaming come un .xlsx.
  • Grafici. Un grafico che punta a una tabella Excel o a colonne intere vede i dati nuovi; uno con range fisso (Dati!$A$2:$A$21, com'è nella fixture) no: il sink non riscrive i riferimenti del grafico, quindi nel modello conviene fondarlo su una tabella.
  • Apertura in Excel. I test aprono il pacchetto con JSZip ed excelSource; che Excel lo apra senza richiesta di riparazione si verifica a mano (kit docs/research/excel-verifica/).

Append su un workbook esistente ​

Il sink apre il pacchetto e modifica solo il foglio, la sua tabella e workbook.xml. Ogni altra parte (grafici, pivot, commenti, convalide, stili, macro di un .xlsm) resta identica byte per byte: un test confronta l'hash di ogni parte prima e dopo. Le parti si riscrivono tagliando il testo originale nei punti che cambiano, non rigenerando il documento.

  • Colonne per titolo. I titoli di columns (o i campi dei record) si cercano nella riga d'intestazione del foglio (headerRow); l'ordine delle colonne nel foglio non conta e le colonne non dichiarate restano vuote. Un titolo che il foglio non ha diventa una colonna nuova (sotto).
  • Stile. Ogni riga nuova copia lo stile della riga dati precedente, colonna per colonna. Una Date si scrive solo in una colonna che ha già un formato data nella riga precedente: il sink non aggiunge stili (styles.xml non cambia) e non scrive una data che Excel mostrerebbe come numero.
  • Tabella. Se il foglio ha una tabella Excel che parte dalla riga d'intestazione, il suo ref e il suo filtro automatico si estendono alle righe nuove. dimension si aggiorna; un autoFilter del foglio senza tabella si estende se arriva all'ultima riga.
  • Formule dichiarate. Una colonna può avere formula: invece di leggere il campo, il sink scrive quella formula nelle righe nuove (mai sulle aggiornate di un upsert). Il testo ha i segnaposto {row} (la riga della cella) e {prev} (quella sopra), con o senza = iniziale: { field: "iva", title: "Iva", formula: "=B{row}*0.22" }. Nel file le funzioni stanno in inglese (SUM, IF), anche se l'Excel in italiano le mostra come SOMMA e SE. La cella si scrive senza valore in cache: la calcola Excel all'apertura (fullCalcOnLoad). Vale anche per un foglio o un workbook che il sink crea. Una formula vuota, un segnaposto sconosciuto ({riga}) o una colonna con formula dentro key sono un ConfigError.
  • Formule ripetute (repeatFormulas, default true). Nelle colonne non dichiarate con formula che nella riga dati precedente hanno una formula, la riga nuova ne riceve una copia traslata: i riferimenti A1 relativi seguono la riga (B7 diventa B8), quelli con $ sulla riga restano, e i riferimenti strutturati di tabella (tblOrdini[[#This Row],[Quantità]]) si copiano uguali perché non dipendono dalla riga. Una formula con un riferimento ad un altro foglio (Altro!A1) non si sa traslare: la run si ferma con un LoadError che nomina cella e formula, senza toccare il file; dichiara la formula della colonna o metti repeatFormulas: false, che lascia quelle celle vuote. Una formula dichiarata prevale sulla ripetuta. Le formule fuori dalla tabella o dalle colonne scritte non si toccano.
  • Formattazione condizionale e convalide. Gli sqref di conditionalFormatting e dataValidation (e gli xm:sqref delle versioni estese x14) che arrivano esattamente all'ultima riga dati si allungano fino alla riga nuova, anche quando lo sqref ha più intervalli (A2:A21 C2:C15: si estende solo il primo); gli altri restano com'erano. Excel non lo fa da solo su un file scritto da fuori. Con replace non si toccano.
  • Pivot. Se una pivot ha per sorgente la tabella che cresce (worksheetSource name="…") la sua cache riceve refreshOnLoad="1" e Excel la ricarica all'apertura. Una sorgente a intervallo fisso (ref="A1:D21") non vede le righe nuove e la sua cache resta com'è.
  • Ricalcolo. Se il workbook contiene formule si imposta fullCalcOnLoad: senza, Excel mostra i valori in cache, ormai vecchi. La calcChain si toglie (con il suo tipo di contenuto e la sua relazione) solo se il sink scrive una formula; altrimenti resta com'è.
  • Ordine dello schema. Gli elementi aggiunti stanno nella posizione dello schema (calcPr dopo definedNames, prima di pivotCaches): fuori posto Excel ripara il file scartando il foglio. Un test lo verifica sul pacchetto.
  • Si rifiuta, non si corrompe. Con un LoadError che dice il motivo e senza toccare il file: file che non è un pacchetto zip, foglio inesistente con onMissingSheet: "error" (elenca quelli che ci sono), righe sotto la tabella, tabella con la riga dei totali, data in una colonna senza formato data, oltre 1.048.576 righe.

Le formule condivise o matriciali della riga precedente non si ripetono (la cella resta vuota con il suo stile). Le stringhe nuove sono sempre in linea, non entrano in sharedStrings.xml. L'apertura in Excel vero non è automatizzata (kit docs/research/excel-verifica/). Il caso «file aperto in Excel» e l'impronta cambiata sono in «Concorrenza, file aperto in Excel e memoria».

Cosa si scrive ​

  • Stringa → sempre testo (cella in linea), mai formula, anche se inizia con = o @.
  • Numero → cella numerica; un intero oltre 15 cifre significative (e un bigint) si scrive come testo, perché Excel lo arrotonderebbe e un identificativo cambierebbe. Un decimale resta un numero anche con più di 15 cifre significative: è un importo o una misura, e come testo uscirebbe dai calcoli. Il tetto vale quindi per gli interi, che sono gli identificativi.
  • Date → numero seriale base 1900 con il format della colonna, o yyyy-mm-dd hh:mm:ss. Booleano → booleano.
  • null / undefined → cella non scritta.

Validazione e atomicità ​

Il sink raccoglie il batch e lo valida tutto prima di toccare il file: se qualcosa non va si ferma con un LoadError che elenca i record invalidi (riga e campo, fino a 20). Rifiutati: NaN, Infinity, testi oltre 32.767 caratteri, caratteri non validi in XML, date prima del 01/03/1900, tipi che una cella non sa tenere, titoli vuoti o ripetuti, oltre 1.048.576 righe o 16.384 colonne. Il workbook si scrive con la sostituzione atomica dello storage (atomic-replace): se la scrittura fallisce non resta un file a metà. excelSink rifiuta (con ConfigError) uno storage che non dichiara quella capacità; oggi è il solo filesystem.

Se un solo record è invalido il file resta identico a prima (i test ne confrontano l'hash), anche quando esiste già.

excelValidate: gli stessi controlli, record per record ​

ts
import { excelSink, excelValidate } from "@noeva/etl-core/connectors/excel";

const columns = [{ field: "id" }, { field: "nota" }];
graph("note")
  .source("src", source)
  .transform("src", "valida", excelValidate("valida", { columns }), {
    deadLetter: rejects,
  })
  .sink(
    "valida",
    excelSink(storage, {
      mode: "append",
      path: "note.xlsx",
      sheet: "A",
      columns,
    }),
  );

Mettila prima del sink con un deadLetter sul nodo: i record invalidi vanno negli scarti e gli altri si scrivono, invece di fermare l'intero batch. Usa la stessa funzione del sink, quindi stesse regole e stessi messaggi (nel sink con il prefisso riga N, ). Il record valido passa invariato; l'errore (ValidationError) elenca tutti i motivi del record e non contiene mai i valori. Con columns un campo non dichiarato è un errore; con sheet un nome non valido è un ConfigError alla costruzione. I limiti del batch (righe e colonne del foglio) restano al sink.

upsert: aggiorna per chiave o aggiunge ​

ts
excelSink(storage, {
  path: "note-spesa.xlsx",
  mode: "upsert",
  key: ["id"],
  sheet: { field: "utente" },
});

key è obbligatoria (uno o più campi, ciascuno in columns se dichiarate) e si cerca solo nel foglio di destinazione: se una riga ha la stessa chiave se ne aggiornano le celle, altrimenti il record va in fondo come in append (stessi fogli nuovi, colonne nuove, stili e tabelle).

  • Confronto canonico: tutto a testo, spazi ai lati tolti, date in ISO, maiuscole distinte; «42» e 42 sono la stessa chiave.
  • Chiave assente, nulla o vuota (anche una sola parte di una chiave composta): record invalido, il batch non si scrive.
  • Stessa chiave due volte nel batch: si applicano in ordine, vince l'ultimo (la riga resta dove l'ha messa il primo).
  • Più righe del foglio con la stessa chiave sono un errore solo se un record del batch le incontra; il messaggio nomina foglio, chiave e righe. Quelle che nessun record incontra non contano.
  • Cella nulla in un record che aggiorna: la cella del foglio non si tocca. Una cella aggiornata tiene il suo stile; una data si scrive solo in una cella che ha già un formato data (nessuno stile nuovo). Se si sovrascrive una formula si toglie la calcChain e si imposta fullCalcOnLoad.
  • Report: <nome>:excel.righe-aggiunte, .righe-aggiornate, .righe-invariate; written è aggiunte più aggiornate. Con tutto invariato il file non si riscrive.

Il caso guida delle note spesa — un foglio per utente, sheet: { field: "utente" }, key: ["id"] — è coperto da un test che fa i quattro passi (file assente creato col foglio di A; l'utente B crea il suo foglio; A aggiunge in fondo; la stessa nota con id esistente aggiorna la riga) e rilegge tutto con excelSource({ sheets: "all", sheetField: "utente" }).

Operazioni distruttive: replace e cancellaQuando ​

Sono le due operazioni che tolgono dati. Runbook, con come tornare indietro: operazioni distruttive di excelSink.

ts
// replace: svuota il foglio e lo riscrive
excelSink(storage, {
  mode: "replace",
  destructive: true,
  path: "report.xlsx",
  sheet: "Dati",
});

// upsert che cancella: il record che soddisfa la condizione toglie la sua riga
excelSink(storage, {
  mode: "upsert",
  key: ["id"],
  cancellaQuando: { field: "stato", equals: "annullata" },
  cancellaRighe: "rimuovi", // il default; "svuota" lascia un buco
  destructive: true,
  path: "note.xlsx",
  sheet: { field: "utente" },
});
  • replace toglie le righe sotto l'intestazione e scrive il batch (stile e formule delle colonne calcolate si prendono dall'ultima riga dati che c'era; la tabella si restringe). Intestazione, righe sopra, altri fogli e ogni altra parte del pacchetto restano identiche. Se il workbook o il foglio mancano si creano come in append. Un batch vuoto non tocca il file. Non è retrySafe.
  • cancellaQuando: { field, equals } (solo upsert): il record con record[field] === equals cancella la riga con la sua chiave; se la riga non c'è, nessun effetto (e il record non si aggiunge). Vale l'ordine di arrivo: l'ultimo record con una chiave decide. Il campo non è una colonna, a meno che non sia in columns. Il record che cancella deve avere la chiave; gli altri campi possono mancare.
  • cancellaRighe dice come si cancella. "rimuovi" (default) toglie la riga e fa salire quelle sotto, riscrivendo i riferimenti del foglio (tabella sotto); dove il layer non sa riscriverli l'operazione fallisce prima di scrivere, con un errore che nomina cosa lo impedisce, e il file resta quello di prima. "svuota" lascia la riga come buco, con le celle vuote e il loro stile, senza spostare nulla né toccare riferimenti: è la via d'uscita quando "rimuovi" rifiuta. excelSource salta le righe vuote.
  • destructive: true è richiesto da replace e da cancellaQuando: senza, excelSink fa fallire la costruzione del grafo. Il flag compare nel descriptor (DAG e report).
  • Report: .righe-cancellate conta le righe svuotate; written non le include.
  • Se una cella svuotata aveva una formula, o replace toglie righe con formule, si toglie la calcChain e si imposta fullCalcOnLoad. Lo stesso vale per "rimuovi", che toglie sempre la calcChain (le sue voci puntano a celle che si spostano) e ricalcola all'apertura.

Cosa riscrive "rimuovi" e cosa rifiuta (v1) ​

Togliere la riga 5 sposta in su le righe 6 in poi; un riferimento a un intervallo si restringe delle righe tolte che contiene, uno a una riga tolta diventa #REF! (come in Excel). Il resto del pacchetto resta identico byte per byte.

Elementov1Come
Righe e celle (row r, c r, dimension)riscriverinumerate
Formule del foglio e degli altri fogliriscriveriferimenti A1 (anche con $, righe intere, 'Foglio'!)
Formule con un riferimento che sparisceriscrive#REF!, come Excel
Formule condivise (t="shared")rifiutase citano le righe del foglio, o se l'origine sta in una riga tolta
Formule di matrice (t="array")riscriverifiuta se si estendono su una riga tolta
Tabelle (ref, filtro)riscriverifiuta se togliere le righe cancella l'intestazione, la riga dei totali o tutte le righe di dati
Filtro automatico, ordinamento (sortState)riscriveref ristretto
Celle uniteriscriveun'unione ridotta a una cella si toglie
Formattazione condizionale, convalideriscrivesqref; le formule solo se con riferimenti assoluti, altrimenti rifiuta
Nomi definiti (anche area di stampa e filtro)riscrivetesto del nome
Collegamenti interni, errori ignorati, intervalli protettiriscriveref/sqref
Grafici, immagini, disegnirifiutail foglio ha un disegno; o un grafico di un altro foglio ne usa gli intervalli
Tabelle pivotrifiutasul foglio, o con il foglio come sorgente
Commentirifiutaposizione legata alla cella
extLst con riferimenti (formattazioni e sparkline estese)rifiutaxm:sqref, xm:f
Scenari, viste personalizzate, celle osservate, consolidamento dati, tabelle dati (what-if)rifiuta

INDIRECT e simili, che costruiscono il riferimento da un testo, non si riscrivono: stessa cosa che fa Excel. I rifiuti nominano l'elemento e suggeriscono cancellaRighe: "svuota".

retrySafe ​

upsert è retrySafe (anche con cancellaQuando): ripetere lo stesso load dà lo stesso foglio (le righe già presenti risultano invariate). append non lo è: ripeterlo aggiungerebbe le righe due volte, e replace nemmeno, e retry e rateLimit sul nodo fanno fallire build().

Concorrenza, file aperto in Excel e memoria ​

Una sola pipeline scrive un dato workbook. Il sink protegge da una scrittura altrui, non coordina due scrittori: due pipeline sullo stesso file si contendono l'impronta e una delle due fallisce.

  • Impronta. Il sink legge l'impronta del file prima di leggerlo e la ricontrolla subito prima di sostituirlo. Se è cambiata (qualcuno ha salvato nel frattempo) non sostituisce e dà un errore transitorio: la modifica di altri non si perde. Resta una finestra minima fra il controllo e la rinomina atomica. Vale per gli storage con la capacità fingerprint (oggi il filesystem).
  • File aperto in Excel. Se accanto al file esiste ~$nome.xlsx il sink non scrive e dà un errore transitorio che lo spiega. onFileApertoInExcel: "ignore" lo ignora (default "error").
  • Due sink sullo stesso workbook. Il sink dichiara exclusiveTarget (storage e percorso): build() rifiuta un grafo con due sink Excel sulla stessa chiave. Rimedio: un solo sink, o una union dei flussi prima del sink.
  • Tetto di memoria. Il foglio da riscrivere si tiene intero in memoria (circa 16 volte i suoi byte in heap: 200k righe sono circa 400 MB). maxSheetBytes è il tetto in byte decompressi del foglio, default 64 MiB; oltre, la run si ferma con un errore che nomina l'opzione e il file resta intatto.
  • Storage senza sostituzione atomica. excelSink lo rifiuta alla costruzione (ConfigError).

Layer OOXML ​

Il sink usa un layer proprio su JSZip (src/connectors/excel/ooxml/), non exceljs, che resta solo per la lettura. creaWorkbook crea un pacchetto nuovo, aggiungiRighe, upsertRighe e (con sostituisci) la sostituzione modificano uno esistente; lo stesso layer serve excelTask (rinomina e cancellazione di fogli, colonne, riscrittura dei riferimenti). Excel pretende l'ordine dello schema degli elementi di un foglio (un legacyDrawing dopo extLst fa scartare il foglio pur aprendosi in LibreOffice): ORDINE_ELEMENTI_FOGLIO lo fissa in un posto solo e un test lo verifica aprendo il pacchetto. Il test apre il file con excelSource e con JSZip; l'apertura in Excel vero non è automatizzata (vedi il kit docs/research/excel-verifica/, da rifare prima di un rilascio che tocca il layer).

Descriptor e report ​

kind: "excel", con storage, path, foglio, modalità e destructive (true per replace e cancellaQuando, altrimenti no; con cancellaQuando anche cancellaQuando col solo nome del campo e cancellaRighe, rimuovi o svuota). Le righe aggiunte si contano in <nome>:excel.righe-aggiunte su ctx.metrics (e in written); in upsert anche .righe-aggiornate e .righe-invariate, e con cancellaQuando .righe-cancellate.

excelTask: operazioni senza record ​

Un nodo task che modifica la struttura del workbook, senza record: parte dagli edge di controllo (after), per esempio dopo il caricamento del sink sullo stesso file.

ts
import { excelSink, excelTask } from "@noeva/etl-core/connectors/excel";

graph("carica-e-sistema")
  .source("righe", righe)
  .sink(
    "righe",
    excelSink(storage, {
      name: "carica",
      path: "vendite.xlsx",
      sheet: "Dati",
      mode: "append",
    }),
  )
  .task(
    "sistema",
    excelTask(storage, {
      path: "vendite.xlsx",
      operations: [
        { op: "reorderSheets", order: ["Dati", "Report"] },
        { op: "renameColumn", sheet: "Dati", from: "Importo", to: "Totale" },
        { op: "renameSheet", from: "Report", to: "Riepilogo" },
      ],
    }),
    {
      after: ["carica"],
      purpose:
        "Mette i fogli in ordine e rinomina la colonna dopo il caricamento.",
    },
  )
  .build();

Una lettura, tutte le operazioni in ordine, un solo salvataggio atomico. Le operazioni lavorano sullo stesso pacchetto in memoria: se una è rifiutata (con il suo numero nel messaggio) non si scrive nulla e il file resta com'era. Le parti che nessuna operazione tocca restano identiche byte per byte. Valgono le regole del sink su impronta, onFileApertoInExcel, maxSheetBytes e storage con atomic-replace; il workbook deve esistere (altrimenti TaskError).

OperazioneChe cosa cambia
{ op: "reorderSheets", order }L'ordine dei <sheet>; gli indici dei nomi locali (localSheetId) e il foglio attivo (activeTab, firstSheet) li seguono. I fogli citati vanno in testa nell'ordine dato (senza badare alle maiuscole), gli altri seguono. Nessun altro riferimento.
{ op: "renameColumn", sheet, from, to, headerRow? }La cella d'intestazione (testo in linea, stile invariato) e, se la colonna sta in una tabella, il nome della colonna nella tabella, le sue formule calcolate e le formule con riferimento strutturato che la citano (tbl[Importo], [@Importo], tbl[[#This Row],[Importo]], anche nei nomi definiti). Fuori da una tabella cambia solo la cella.
{ op: "renameSheet", from, to }Il nome del foglio e ogni riferimento per nome: formule di tutti i fogli (Dati!A1, 'Dati 2'!$B:$B, riferimenti 3D), nomi definiti, formattazione condizionale e convalide (anche quelle estese xm:f), colonne calcolate e riga dei totali di tabella, collegamenti interni. Gli apici si aggiungono o si tolgono secondo il nome nuovo. Un grafico, una tabella pivot o una forma collegata a una cella che citano il foglio fanno rifiutare l'operazione, nominandoli.
{ op: "deleteSheet", sheet }Distruttiva. Toglie il foglio, la sua relazione, e ciò che solo lui possedeva (tabelle, disegni con grafici e immagini, commenti); i nomi locali del foglio spariscono, gli indici dei nomi locali degli altri e il foglio attivo si riallineano. Se qualcosa fuori dal foglio lo cita (formule, nomi, grafici, pivot, collegamenti) rifiuta elencando i riferimenti che diventerebbero #REF!. Rifiuta anche se il foglio ha una tabella pivot o è l'unico visibile. Assente: «già assente».
{ op: "deleteColumns", sheet, columns, headerRow? }Distruttiva. Cancella le colonne con quei titoli (riga d'intestazione, senza badare alle maiuscole), dati compresi; quelle a destra scalano. Titoli assenti: «già assente».
{ op: "reorderColumns", sheet, order, headerRow? }Le colonne citate prendono, nell'ordine dato, le posizioni che occupano (da sinistra a destra); le altre restano. Serve almeno due titoli; un titolo che non c'è è un rifiuto; l'ordine già raggiunto è «già fatto».
{ op: "insertColumns", sheet, columns, headerRow? }Per ogni { title, after }, in ordine, una colonna nuova col titolo subito a destra di after (può essere una appena inserita); la cella d'intestazione ha lo stile di quella di after. Dentro una tabella la colonna entra nella tabella (ref, tableColumns, filtro); a destra dell'ultima colonna no. Un titolo già presente è «già fatto».

destructive: true (opzione del task) è obbligatorio se c'è deleteSheet o deleteColumns: senza, excelTask fa fallire la costruzione del grafo (ConfigError). Compare nel descriptor e nel report. Runbook: operazioni distruttive.

Cosa riscrivono le operazioni che spostano o rinominano, e cosa rifiutano ​

Le colonne seguono la regola unica «rifiuta se non sa riscrivere i riferimenti»: tutto si calcola prima, quindi un rifiuto (con il numero dell'operazione e cosa lo impedisce) non lascia nessun file modificato. Le formule ricalcolano all'apertura e la calcChain si toglie. Per le colonne valgono le stesse regole di cancellaRighe: "rimuovi" (tabella sopra), con la colonna al posto della riga:

ElementoColonneCome
Celle (c r), dimension, cols (larghezze e stili)riscrivespostate, tolte o aggiunte; spans delle righe cambiate si toglie
Formule del foglio e degli altri, nomi definitiriscriveriferimenti A1 (con $, colonne intere B:D, 'Foglio'!); cancellazione: #REF! o intervallo ristretto; inserimento: intervallo che attraversa si allarga
Intervalli che dopo un riordino non sono più contiguirifiutanomina la formula o il nome
Formule condivise (t="shared") e di matricerifiutase citano colonne che cambiano o si estendono su una colonna tolta / spezzata
Tabelle: ref, tableColumns (nomi, ordine, count), filtro (colId), colonne calcolateriscriverifiuta se perderebbe tutte le colonne, se un riordino porta colonne dentro o fuori, se è legata a una query o a una mappa XML
Riferimenti strutturati a una colonna cancellatarifiutatbl[Importo], [@Importo] in formule, colonne calcolate e nomi; vale anche per intervalli tbl[[A]:[C]] se si cancella o riordina
Filtro, ordinamento, celle unite, collegamenti, convalide, formattazione condizionaleriscriveref/sqref (un'unione ridotta a una cella e un intervallo sparito si tolgono); formule solo con colonne assolute, altrimenti rifiuta
Graficirifiutase ne usano intervalli che cambierebbero; un grafico che cita colonne non toccate non impedisce nulla
Tabelle pivotrifiutacon il foglio (o una sua tabella) come sorgente
Immagini, disegni, commenti, altre parti legate al fogliorifiutail foglio ha un disegno o commenti
extLst con riferimenti, interruzioni di pagina di colonna, scenari, viste personalizzate, celle osservate, consolidamento dati, tabelle dati (what-if)rifiuta
Viste del foglio (selezione, riquadri bloccati)non riscrivecosmetico: un riquadro bloccato resta dov'era

INDIRECT e simili, che costruiscono il riferimento da un testo, non si riscrivono, come in Excel. renameSheet non vede il codice VBA di un .xlsm né i file esterni che citano il workbook.

Idempotenza. Riordinare fogli o colonne già in ordine, rinominare con origine assente e destinazione presente, inserire un titolo già presente è «già fatto»; cancellare ciò che non c'è è «già assente»: il file non si riscrive e il report lo dice. Origine e destinazione entrambe assenti, o entrambe presenti, è un errore (per renameColumn anche se differiscono per sole maiuscole; per renameSheet una rinomina di sole maiuscole è una rinomina vera). Per questo il task è retrySafe e ammette retry.

Descriptor e report. kind: "excel-task", con storage, path, una voce per operazione e destructive (true se c'è deleteSheet o deleteColumns). Il report del nodo ha operations (done, already-done o already-absent per ciascuna), done, alreadyDone (le ultime due insieme) e destructive; le metriche sono <nome>:excel.operazioni-fatte e .operazioni-gia-fatte.

Promesse provate con Excel ​

Excel per Mac 16.103.4 (ETL-326) è il giudice di ciò che promette il layer; i test automatici non lo sostituiscono e non lo possono: aprono il pacchetto con JSZip ed excelSource.

PromessaDove si vede
Un file scritto dal connettore si apre senza richiesta di riparazione (ordine degli elementi, tabelle, relazioni).Ogni file del kit, colonna «si apre senza avviso?».
Con formule nel workbook i valori sono ricalcolati all'apertura (fullCalcOnLoad); la calcChain tolta non dà avvisi.File con formule, colonna «i valori sono ricalcolati?».
La pivot con per sorgente una tabella si aggiorna all'apertura (refreshOnLoad); con sorgente a intervallo fisso no.File con righe aggiunte in fondo a una tabella con pivot.
Rinominare una colonna di tabella cambia cella e nome insieme: Excel non ripara la tabella.File di excelTask con renameColumn.
Grafici, pivot, commenti, convalide, formattazione condizionale, forme e macro restano al loro posto.File di append, upsert e rimozione righe su Prova.xlsx e ProvaMacro.xlsm.
La macro di un .xlsm resta identica e si lancia.File .xlsm.

Stato. La verifica sui file prodotti dal connettore finito è stata eseguita il 2026-11-06 con Excel per Mac 16.103.4, a mano, con il kit docs/research/excel-verifica/: esito pulito (nessun avviso di riparazione, valori come attesi su 19 file aperti, 4 rifiuti attesi confermati dal manifest), in docs/research/excel-verifica/ESITO.md. Vale per quella versione di Excel, su Mac: Excel per Windows, Excel online e le versioni precedenti non sono provati. Va rifatta prima di ogni rilascio che tocca il layer.

Che cosa non si copre ​

  • Grafici, pivot, sparkline, forme e macro da configurazione. Si preservano e, dove possibile, si estendono (intervalli); per averli in un file nuovo si porta un modello (template, sheetTemplate). Non si modifica il VBA.
  • Formattazione condizionale e convalide da configurazione. Si preservano e si estendono; non si creano.
  • Formati .xls e .ods in ingresso.
  • Storage remoti. ftp, s3 e sftp non offrono la sostituzione atomica né l'impronta: il sink e il task li rifiutano alla costruzione.
  • Riordinare o inserire righe «in mezzo»: si usano sort con replace, e append/upsert.
  • Lettura incrementale con watermark.
  • Un motore di calcolo: Excel ricalcola all'apertura; il source legge il valore in cache.
  • Lock fra run, backup automatici, percorso del file da campo, file protetti da password.

Test ​

I test usano i file salvati da Excel in tests/fixtures/excel/ (Prova.xlsx, ProvaMacro.xlsm): Excel ripara ciò che altri produttori scrivono, quindi le fixture non si costruiscono a mano. Base 1904, formule in errore e celle unite si provano su workbook prodotti da exceljs nel test.

Noeva è un marchio registrato di 4D S.R.L.