Tema
Connettore: SQL Server
Source e Sink per Microsoft SQL Server, basati sul driver mssql (tedious, puro JS). Codice: src/connectors/sqlserver/.
Configurazione
loadMssqlConfig(prefix?, env?) costruisce la config mssql dalle variabili <PREFIX>HOST|PORT|USER|PASSWORD|DATABASE (default prefisso MSSQL_) e opzionali <PREFIX>ENCRYPT / <PREFIX>TRUSTCERT. Default dev (cert self-signed): encrypt=true, trustServerCertificate=true. In produzione usare MSSQL_TRUSTCERT=false con un certificato valido. Credenziali solo in .env (vedi .env.example).
Le variabili sono dichiarate una volta in MANIFEST_MSSQL: cinque obbligatorie, senza default nemmeno per la porta, ed ENCRYPT / TRUSTCERT (default true, solo false li spegne). Con variabili mancanti il loader lancia un ConfigError che le elenca tutte, anche nel campo mancanti. Le istanze passano dal registry (loadMssqlRegistry, MSSQL_INSTANCES): la default usa MSSQL_HOST…, la dwh usa MSSQL_DWH_HOST….
createMssqlPool(config, { requestTimeout? }) apre il ConnectionPool di mssql su quella config, senza connettersi: una pipeline non importa il driver, si connette con connect() e ne chiude il ciclo di vita con close(). requestTimeout (ms) alza il tempo massimo di una richiesta oltre i 15 s del driver, per una query che lo richiede; la config dell'istanza non cambia. Per etl env check, istanzaMssqlDichiarata(id) è l'istanza da passare ad ambienteDellaPipeline nell'environment della pipeline, con utente e password che il piano non mostra.
Nel piano, dove non c'è un pool, istanzaMssqlPerIlPiano(id, env) dà host, porta e database dell'istanza id con le loro variabili, o l'esempio (sqlserver.esempio) se l'istanza non si costruisce. La carica loadMssqlConnessione, che non legge le altre istanze: una dwh incompleta non porta all'esempio la default. dettagliConnessioneSql ne fa le righe del descriptor, anche per i lookup (sqlserver-lookup), con variabile e origine e in fondo la credenziale. Vedi la convenzione dei descriptor.
ts
import {
createMssqlPool,
loadMssqlConfig,
mssqlSource,
mssqlSink,
} from "@noeva/etl-core/connectors/sqlserver";
const pool = await createMssqlPool(loadMssqlConfig("MSSQL_")).connect();
try {
const source = mssqlSource<Order>({
pool,
instanceId: "erp",
query:
"SELECT id, amount, updated_at FROM dbo.orders WHERE updated_at >= :watermark ORDER BY updated_at",
watermark: {
column: "updated_at",
type: "timestamp",
initial: "1970-01-01T00:00:00",
},
highWaterMark: 1000,
});
const sink = mssqlSink<FactOrder>({
pool,
table: "dbo.fact_orders",
columns: ["orderId", "amount", "date"],
conflictTarget: ["orderId"],
});
} finally {
await pool.close(); // il chiamante possiede il ciclo di vita del pool
}Source — mssqlSource
- Streaming (
request.stream) con backpressure viapause()/resume(): non carica l'intero result-set. - Parametri nominali via
inputs(@nome). - Incrementale: con l'opzione
watermark, vedi sotto. - Rispetta
ctx.signal(annullamento). Errori di estrazione →ExtractError.
Source incrementale: watermark
ts
mssqlSource({
pool,
instanceId: "erp",
query:
"SELECT id, importo, updated_at FROM dbo.ordini WHERE stato = @stato AND updated_at >= :watermark",
inputs: { stato: "aperto" },
watermark: {
column: "updated_at", // colonna del risultato
type: "timestamp", // "timestamp" | "integer"
initial: "1970-01-01T00:00:00", // da dove parte la prima run
lookback: 5 * 60_000, // opzionale, millisecondi, solo per "timestamp"
},
});Il runner legge lo stato di pipeline all'avvio e lo committa a run conclusa (nodo source). Quello che fa il connettore:
- Il predicato lo scrivi tu, con
>=e il segnaposto:watermark. La source lo lega al parametro@watermark: quel nome non può stare fra gliinputs. - Alla costruzione rifiuta una query senza
:watermark(sarebbe un full scan a ogni run) e un:watermarksenza l'opzionewatermark. Conta solo il codice, non i letterali né i commenti. columnva selezionata. Se non è nel risultato l'estrazione fallisce; una riga con la colonna aNULLnon sposta il watermark.- Precisione. Un
datetime2arriva dal driver comeDate, ferma al millisecondo, più la frazione restante: il watermark si ricompone fino al microsecondo, troncando (con>=troncare rilegge una riga, non la salta). Il testo è senza offset, nelle cifre UTC dellaDate: vale conuseUTCattivo, che è il default del driver. Una colonna già testuale passa com'è. - Colonne
datetimeesmalldatetime. Il watermark è legato come testo con sei cifre decimali, e SQL Server non converte indatetimeuna stringa con più di tre: il predicato va scritto con un cast,WHERE updated_at >= CAST(:watermark AS datetime2). Condatetime2edatetimeoffsetnon serve. rowversionnon è un tipo di watermark: si espone come intero dalla query (CONVERT(bigint, rv) AS rv) e si dichiaratype: "integer". Unbigintarriva come stringa.- Niente
ORDER BYobbligatorio: il massimo si calcola su tutte le righe estratte. - Il sink a valle dev'essere idempotente: con
>=le righe al confine si rileggono.
Sink — mssqlSink
- Due modalità: l'upsert descritto qui (default) e la modalità SCD2, che conserva la storia.
- Idempotente:
MERGE ... ON (conflictTarget)in transazione.conflictTargetvuoto → INSERT non idempotente (sconsigliato).- tutte le colonne nella chiave → MERGE con solo
WHEN NOT MATCHED(insert-if-absent).
- Batch (
batchSize, default 200): SQL Server limita a 2100 i parametri per richiesta, quindibatchSize × numColonnedeve restare sotto tale soglia. - Dichiara
Sink.held(in lavorazione nei contatori dal vivo): le righe nel batch non ancora scritte, anche in SCD2. Conretry/rateLimitsul nodo conta invece il blocco del runner. Vedi sink, «Dal vivo». - Errori di caricamento →
LoadErrorconROLLBACK.
Sink in modalità SCD2: dimensioni storicizzate
Con mode: "scd2" il sink non fa MERGE: quando un'entità cambia chiude la versione corrente e ne apre una nuova, alla data di riferimento della run. È il tMSSqlSCD di Talend.
Opzioni, parametri della run (referenceDate, scd2Rehash), classificazione dei record, errori e report sono identici a Postgres: la logica è la stessa, in src/connectors/shared/scd2/, ed è descritta nella scheda Postgres. Qui c'è solo ciò che cambia.
ts
const sink = mssqlSink<ClienteRiga>({
pool,
table: "dbo.dim_cliente",
mode: "scd2",
columns: ["codice", "cliente_sk", "nome", "citta", "riga_hash"],
naturalKey: ["codice"],
hashColumn: "riga_hash",
validFrom: "valid_from",
validTo: "valid_to",
isCurrent: "is_current", // opzionale
versionKey: { column: "versione_sk", entity: "cliente" },
executionIdColumn: "execution_id",
});I due avvisi che il sink non può verificare
⚠️ hashRow deve coprire esattamente le colonne di business scritte. L'hash è opaco: il sink lo confronta, non lo ricalcola. Una colonna scritta ma non tracciata cambia senza aprire una versione.
⚠️ versionKey.entity deve essere la stessa entity della surrogateKey a monte, altrimenti chiave durevole e chiave di versione vivono in namespace diversi.
DDL di riferimento
Il sink non crea niente. La tabella si prepara così:
sql
CREATE TABLE dbo.dim_cliente (
versione_sk uniqueidentifier NOT NULL -- chiave di versione, scritta dal sink
CONSTRAINT pk_dim_cliente PRIMARY KEY NONCLUSTERED,
cliente_sk uniqueidentifier NOT NULL, -- chiave durevole, da surrogateKey
codice nvarchar(50) NOT NULL, -- chiave naturale
nome nvarchar(200) NOT NULL,
citta nvarchar(200) NOT NULL,
riga_hash char(67) NOT NULL, -- "v1:" + 64 caratteri esadecimali
valid_from date NOT NULL,
valid_to date NULL, -- NULL = versione corrente
is_current bit NOT NULL, -- solo se si dichiara isCurrent
execution_id varchar(64) NOT NULL,
CONSTRAINT ck_dim_cliente_validita CHECK (valid_to IS NULL OR valid_to > valid_from)
);
-- al più una versione al giorno; clustered: tiene vicine le versioni della stessa entità
CREATE UNIQUE CLUSTERED INDEX dim_cliente_storia
ON dbo.dim_cliente (codice, valid_from);
-- al più una versione corrente per entità
CREATE UNIQUE INDEX dim_cliente_corrente
ON dbo.dim_cliente (codice) WHERE valid_to IS NULL;- La PK sulla chiave di versione è non clustered: un UUIDv5 è distribuito a caso, e come indice clustered frammenterebbe la tabella.
- Le colonne di validità sono
date, maidatetime: con una lingua di sessione non inglese SQL Server legge'2026-03-04'come 3 aprile in undatetime, mentre in undatela formaYYYY-MM-DDè sempre letta allo stesso modo. execution_idpuò esserevarcharouniqueidentifier: il sink confronta l'identificativo senza badare alle maiuscole.- SQL Server non ha un vincolo contro gli intervalli sovrapposti: i due indici univoci coprono ciò che il sink scrive.
Cosa cambia rispetto a Postgres
- Lock applicativo:
sp_getapplockdi transazione con timeout zero, sulla risorsanoeva-etl:scd2:<table>. Il nome è quello scritto intable:dbo.dim_clienteedim_clientesono due lock. - Parametri per richiesta: vale il limite di 2100, e in modalità SCD2 ogni riga usa
numColonne + 1parametri (ocolonneDellaChiave + 1, se di più). SenzabatchSizeil sink sceglie il batch più grande che ci sta, fino a 200; unbatchSizeesplicito troppo grande è unConfigErroralla costruzione. - Collation: con una collation case-insensitive
"ABC"e"abc"sono la stessa riga per il database ma due chiavi diverse per il sink, e lo stesso vale per uno spazio in coda. La chiave naturale deve arrivare al sink già in forma canonica, come persurrogateKey. Il sink se ne accorge quando le due forme arrivano nello stesso batch (errore «corrispondono alla stessa versione») o sono entrambe nuove (violazione dell'indice univoco). ⚠️ Se arrivano in batch diversi non può accorgersene: la versione corrente rimbalza fra le due forme, una versione nuova a ogni data.
Task — mssqlSqlTask
Esegue statement SQL senza flusso di record, in una transazione: è ciò che gira in un nodo task.
ts
mssqlSqlTask({
pool,
instanceId: "dwh",
name: "svuota-staging",
statements: ["TRUNCATE TABLE dbo.ordini_staging"],
destructive: true,
retrySafe: true,
});- Statement statici, uno per voce, tutti nella stessa transazione:
TRUNCATE TABLE,sp_renameeALTER SCHEMA … TRANSFERsono transazionali, quindi uno scambio di tabelle è atomico. - I valori entrano solo come parametri bind:
@nomenello statement, dichiarato inparams. Nel descriptor compaiono i soli nomi. destructiveva dichiarato e compare nel DAG.retrySafevalefalsese non lo dichiari: solo contrueil nodo ammetteretry.- Errori →
TaskErrorconROLLBACK; la run fallisce. Le righe toccate vanno nel report del nodo. - La parte indipendente dal DBMS è condivisa con
postgresSqlTaske vive insrc/connectors/shared/sql-task.ts. Come scrivere uno scambio, e cosa fare se fallisce: runbook.
Lookup — mssqlLookup
Alimenta il nodo lookup del grafo: risolve un batch di chiavi con un solo round-trip, in sola lettura.
ts
mssqlLookup({
pool,
instanceId: "dwh",
query: "SELECT chiave, ordine FROM v WHERE chiave IN (@chiavi)",
keyColumn: "chiave",
});La parte indipendente dal DBMS (controllo di sola lettura, segnaposto, normalizzazione, raggruppamento) è condivisa con postgresLookup e vive in src/connectors/shared/sql-lookup.ts: la stessa query funziona sui due database.
Il segnaposto
@chiavi(KEYS_PLACEHOLDER) viene espanso in un parametro per chiave (@k0, @k1, …): si generano solo i nomi, i valori restano bindati. Con zero chiavi diventa un insieme vuoto valido, non SQL invalido.keysAs(default"list") sceglie la forma del segnaposto. Con"list"diventa@k0, @k1per unaIN (@chiavi). Con"rows"diventa(VALUES (@k0), (@k1))e la query lo usa come tabella (FROM @chiavi AS k(chiave) JOIN v ON …): serve quando il confronto non è un'uguaglianza, per esempio «la chiave contiene questa colonna». Costa una scansione del lato destro, quindi va usata su poche chiavi. Con zero chiavi il segnaposto diventa un rowset vuoto a colonnanvarchar.In entrambe le forme
@chiaviva scritto una volta sola: viene sostituito solo alla prima occorrenza.keyColumndice quale colonna del result set riassocia le righe alle chiavi. La chiave è normalizzata (trim + maiuscolo) sui due lati, così unUPPER()dimenticato nella vista non produce zero match in silenzio. Al DB partono le chiavi normalizzate senza duplicati; le righe tornano indicizzate per la chiave così come l'ha passata il nodo (" a"riceve le righe diA). Una colonna chiave assente, o non scalare, è unLookupError— non zero risultati.Sola lettura imposta a
build():assertReadOnlyQueryrifiuta tutto ciò che non è una singolaSELECT/WITH(nienteINSERT/UPDATE/DELETE/MERGE/EXEC/INTO, niente;multipli). Commenti e stringhe letterali sono rimossi prima dell'analisi, cosìWHERE nota = 'da aggiornare (update)'passa e una parola vietata dentro un commento no.
⚠️ È un controllo sintattico, non un modello di sicurezza: la garanzia vera che la pipeline non scriva resta l'utenza di sola lettura sul database. Serve a trasformare un errore di configurazione in un fallimento immediato e leggibile.
Errori transitori
Un deadlock (errore 1205, anche annidato in originalError) e gli errori di rete ECONNRESET ed ETIMEDOUT marcano l'errore del sink e del lookup come transitorio (context.transient: true): un nodo con una policy di retry lo ritenta. Il sink ha già annullato la transazione, e il MERGE è idempotente. In modalità SCD2 è transitorio anche il lock della tabella occupato da un altro caricamento.
Sicurezza SQL
- I valori sono sempre parametri (
@p0,@p1, ...), mai concatenati. - Identificatori (tabella/colonne) dal codice, validati e quotati con
[...](quoteIdentifier); stringa non conforme →ConfigError.
Ambiente di test
SQL Server si prova in Docker con l'immagine ufficiale, che è solo linux/amd64: su Apple Silicon gira in emulazione.
Test
- Unit (CI):
src/connectors/sqlserver/{query,config,lookup,task}.test.ts— MERGE building, parsing config, resolver del lookup e task con pool finto. La parte comune del lookup è testata insrc/connectors/shared/sql-lookup.test.ts, quella del task insrc/connectors/shared/sql-task.test.ts. - Integrazione:
tests/integration/mssql.integration.test.ts— MERGE idempotente e streaming contro il container reale. - Modalità SCD2: unit in
src/connectors/shared/scd2/*.test.ts; integrazione intests/integration/scd2.integration.test.ts, con le stesse asserzioni su Postgres e SQL Server. - Integrazione del task:
tests/integration/sql-task.integration.test.ts— truncate, caricamento e scambio consp_rename, atomicità, parametri bind. Svuota e scambia tabelle, quindi gira solo seMSSQL_HOSTè la macchina locale e conETL_TEST_DESTRUCTIVE=1.
bash
docker compose up -d # avvia SQL Server di test
pnpm run test:integration # esegue i test di integrazione (auto-skip senza env)
