Tema
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:
| Nodo | Che cosa fa |
|---|---|
excelSource | Legge uno o più fogli, a flusso, con le colonne abbinate per titolo. |
excelSink | Crea e aggiorna workbook: append, upsert per chiave, replace; fogli nuovi, colonne nuove, formati, formule. |
excelTask | Operazioni 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).
| Opzione | Significato |
|---|---|
path | Il workbook da leggere. Serve path oppure prefix, non entrambi. |
prefix | Legge in sequenza tutti i .xlsx/.xlsm sotto il prefisso (i lock ~$… si saltano). |
sheet | Il foglio, per nome, senza distinguere maiuscole e minuscole. Serve sheet oppure sheets. |
sheets | Più fogli: l'elenco dei nomi o "all" (tutti, nell'ordine del workbook). Ogni foglio ha la sua intestazione. |
sheetField | Il 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"). |
headerRow | La riga d'intestazione, da 1 (default 1). Le righe sopra non sono dati. |
columns | I titoli da leggere: il record contiene solo quelli, abbinati per titolo e non per posizione. |
onMissingSheet | error (default) ferma la run; skip salta quel workbook (scelta esplicita, con un warning nel log). |
instanceId | Id 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), valenull; gli errori si contano in<nome>:excel.errori-di-formulasuctx.metricse non fanno fallire la run. - Date →
Datein 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).
| Opzione | Significato |
|---|---|
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. |
destructive | true obbligatorio con replace e con cancellaQuando: senza, ConfigError alla costruzione. Compare nel descriptor. Vedi «Operazioni distruttive». |
path | Il workbook, percorso fisso. |
sheet | Il 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. |
repeatFormulas | true (default): nelle righe nuove le colonne senza formula ripetono, traslata, la formula della riga precedente. Vedi «Append su un workbook esistente». |
onMissingSheet | Il foglio che manca in un workbook esistente: create (default) lo aggiunge, error ferma la run. |
headerRow | La riga d'intestazione, da 1 (default 1). In un workbook nuovo le righe sopra restano vuote; in uno esistente restano come sono. |
onMissingFile | create (default) crea il workbook; error ferma la run se manca. |
template | Il 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». |
sheetTemplate | Il 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». |
instanceId | Id dell'istanza storage, mostrato nel descriptor. |
name | Nome del nodo, per il report e le metriche (default <storage>:excel). |
onFileApertoInExcel | error (default) blocca la scrittura se esiste ~$nome.xlsx; ignore lo ignora. |
maxSheetBytes | Tetto 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 incolumns. 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 inworkbook.xml, la relazione e il tipo di contenuto. Gli altri fogli e ogni altra parte restano identici (hash). Le stringhe sonoinlineStre gli elementi stanno nell'ordine dello schema. Le date di una colonna senzaformatusano 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.fullCalcOnLoadresta 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 etableColumncol 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;nulllascia 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.
| Opzione | Dove | Significato |
|---|---|---|
columns[].format | colonna | "text", "number" (#,##0.00), "integer", "currency" (#,##0.00 "€"), "percent" (0.00%), "date", "datetime" o { code: "0.000" } |
columns[].width | colonna | Larghezza in caratteri, da 0 (escluso) a 255. Solo nei fogli creati. |
columns[].formula | colonna | Una formula al posto del valore, scritta nelle righe nuove ({row}, {prev}). Vedi «Append su un workbook esistente». |
boldHeader | foglio | Intestazione in grassetto. |
freezeHeader | foglio | Blocca le righe fino all'intestazione (headerRow) compresa. |
autoFilter | foglio | Filtro automatico su intestazione e righe; con table sono i pulsanti di filtro della tabella (che senza non li ha). |
table: "Nome" | foglio | Una tabella Excel su intestazione e righe. Un secondo append la estende come qualunque tabella. |
- Stili solo in aggiunta.
styles.xmlcambia soltanto con elementi in coda (numFmt, un font in grassetto, glixfdicellXfs): gli indici esistenti non si spostano, i formati già presenti con lo stesso codice si riusano, e un test confronta glixfdi prima con quelli di dopo. Se non serve alcuno stile la parte resta identica byte per byte. - Colonne già presenti. Un
formatdichiarato 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 suoformat(nonwidth, che richiederebbe di riscrivere icolsdell'utente). - Date. Una
Datein una colonna conformatha quel formato; senza, il primo stile di data del workbook (o uno aggiunto): si rilegge sempre comeDate. table. Il nome è valido (lettere, cifre,_e.; senza spazi; nonR,Cné un riferimento di cella comeA1) e unico nel workbook, senza badare alle maiuscole e contando i nomi definiti; altrimentiConfigError(nome non valido) o rifiuto senza toccare il file (nome già usato). Non si combina consheet: { 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 unautoFiltersuo, perché Excel ripara una tabella sovrapposta a un filtro di foglio.- Ordine dello schema.
sheetViews(blocco),cols,sheetData,autoFilter,pageMargins,tablePartsstanno nell'ordine diORDINE_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:
- 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.
- Dichiarare. Ciò che il connettore crea da configurazione: workbook, fogli, colonne, formati e larghezze, intestazione in grassetto, blocco, filtro, tabella (questa sezione).
- 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 (eonMissingFileè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 dipath; 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 daltemplate) 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 conid,nameedisplayNamerigenerati e unici (tblVendite→tblVendite_2,_3…; i riferimenti strutturati nelle formule seguono il nome). Il foglio clonato non è selezionato, non porta ilcodeNamedel modello (in un.xlsmdeve essere unico) e ha GUID nuovi. Il modello resta nel workbook. In un foglio clonatotableewidthnon si applicano (la tabella e le larghezze sono quelle del modello); iformatdelle colonne aggiunte sì. Non si clonano i nomi definiti locali al foglio (definedNameconlocalSheetId).- 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 unLoadErrorche 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 conservandovbaProject.binbyte per byte (un test lo prova sulla fixture creata da Excel). Estensione e contenuto devono coincidere: un.xlsmche in realtà è un.xlsx(o il contrario) ferma la run, perché Excel non lo apre. Il layer non crea macro, quindi un.xlsmnuovo nasce solo da untemplate.xlsm. Il source legge un.xlsmin 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 (kitdocs/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
Datesi scrive solo in una colonna che ha già un formato data nella riga precedente: il sink non aggiunge stili (styles.xmlnon 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
refe il suo filtro automatico si estendono alle righe nuove.dimensionsi aggiorna; unautoFilterdel 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 unupsert). 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 comeSOMMAeSE. 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 dentrokeysono unConfigError. - Formule ripetute (
repeatFormulas, defaulttrue). Nelle colonne non dichiarate conformulache nella riga dati precedente hanno una formula, la riga nuova ne riceve una copia traslata: i riferimenti A1 relativi seguono la riga (B7diventaB8), 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 unLoadErrorche nomina cella e formula, senza toccare il file; dichiara la formula della colonna o mettirepeatFormulas: 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
sqrefdiconditionalFormattingedataValidation(e glixm:sqrefdelle versioni estesex14) che arrivano esattamente all'ultima riga dati si allungano fino alla riga nuova, anche quando losqrefha 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. Conreplacenon si toccano. - Pivot. Se una pivot ha per sorgente la tabella che cresce (
worksheetSource name="…") la sua cache riceverefreshOnLoad="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. LacalcChainsi 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 (
calcPrdopodefinedNames, prima dipivotCaches): fuori posto Excel ripara il file scartando il foglio. Un test lo verifica sul pacchetto. - Si rifiuta, non si corrompe. Con un
LoadErrorche dice il motivo e senza toccare il file: file che non è un pacchetto zip, foglio inesistente cononMissingSheet: "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 ilformatdella colonna, oyyyy-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
calcChaine si impostafullCalcOnLoad. - 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" },
});replacetoglie 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 inappend. Un batch vuoto non tocca il file. Non èretrySafe.cancellaQuando: { field, equals }(soloupsert): il record conrecord[field] === equalscancella 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 incolumns. Il record che cancella deve avere la chiave; gli altri campi possono mancare.cancellaRighedice 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.excelSourcesalta le righe vuote.destructive: trueè richiesto dareplacee dacancellaQuando: senza,excelSinkfa fallire la costruzione del grafo. Il flag compare nel descriptor (DAG e report).- Report:
.righe-cancellateconta le righe svuotate;writtennon le include. - Se una cella svuotata aveva una formula, o
replacetoglie righe con formule, si toglie lacalcChaine si impostafullCalcOnLoad. Lo stesso vale per"rimuovi", che toglie sempre lacalcChain(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.
| Elemento | v1 | Come |
|---|---|---|
Righe e celle (row r, c r, dimension) | riscrive | rinumerate |
| Formule del foglio e degli altri fogli | riscrive | riferimenti A1 (anche con $, righe intere, 'Foglio'!) |
| Formule con un riferimento che sparisce | riscrive | #REF!, come Excel |
Formule condivise (t="shared") | rifiuta | se citano le righe del foglio, o se l'origine sta in una riga tolta |
Formule di matrice (t="array") | riscrive | rifiuta se si estendono su una riga tolta |
Tabelle (ref, filtro) | riscrive | rifiuta se togliere le righe cancella l'intestazione, la riga dei totali o tutte le righe di dati |
Filtro automatico, ordinamento (sortState) | riscrive | ref ristretto |
| Celle unite | riscrive | un'unione ridotta a una cella si toglie |
| Formattazione condizionale, convalide | riscrive | sqref; le formule solo se con riferimenti assoluti, altrimenti rifiuta |
| Nomi definiti (anche area di stampa e filtro) | riscrive | testo del nome |
| Collegamenti interni, errori ignorati, intervalli protetti | riscrive | ref/sqref |
| Grafici, immagini, disegni | rifiuta | il foglio ha un disegno; o un grafico di un altro foglio ne usa gli intervalli |
| Tabelle pivot | rifiuta | sul foglio, o con il foglio come sorgente |
| Commenti | rifiuta | posizione legata alla cella |
extLst con riferimenti (formattazioni e sparkline estese) | rifiuta | xm: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.xlsxil 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 unauniondei 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.
excelSinklo 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).
| Operazione | Che 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:
| Elemento | Colonne | Come |
|---|---|---|
Celle (c r), dimension, cols (larghezze e stili) | riscrive | spostate, tolte o aggiunte; spans delle righe cambiate si toglie |
| Formule del foglio e degli altri, nomi definiti | riscrive | riferimenti 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ù contigui | rifiuta | nomina la formula o il nome |
Formule condivise (t="shared") e di matrice | rifiuta | se citano colonne che cambiano o si estendono su una colonna tolta / spezzata |
Tabelle: ref, tableColumns (nomi, ordine, count), filtro (colId), colonne calcolate | riscrive | rifiuta 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 cancellata | rifiuta | tbl[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 condizionale | riscrive | ref/sqref (un'unione ridotta a una cella e un intervallo sparito si tolgono); formule solo con colonne assolute, altrimenti rifiuta |
| Grafici | rifiuta | se ne usano intervalli che cambierebbero; un grafico che cita colonne non toccate non impedisce nulla |
| Tabelle pivot | rifiuta | con il foglio (o una sua tabella) come sorgente |
| Immagini, disegni, commenti, altre parti legate al foglio | rifiuta | il 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 riscrive | cosmetico: 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.
| Promessa | Dove 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
.xlse.odsin ingresso. - Storage remoti.
ftp,s3esftpnon offrono la sostituzione atomica né l'impronta: il sink e il task li rifiutano alla costruzione. - Riordinare o inserire righe «in mezzo»: si usano
sortconreplace, eappend/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.

