Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,31 @@ Il toolkit non gestisce il deployment: scrive nella directory configurata via

---

## Scrivere clean.sql con le macro standard

Il toolkit fornisce **macro SQL DuckDB** precaricate automaticamente in ogni
esecuzione del layer CLEAN. Servono a scrivere `clean.sql` senza riscrivere
ogni volta `TRY_CAST`, `REPLACE` per numeri italiani, `CASE` per flag booleani.

Esempio — invece di:

```sql
TRY_CAST(REPLACE(REPLACE("Importo"::VARCHAR, '.', ''), ',', '.') AS DOUBLE) AS importo,
CASE WHEN TRIM("ETS") = 'X' THEN TRUE ELSE FALSE END AS flag_ets,
TRIM(CAST("Denominazione" AS VARCHAR)) AS denominazione,
```

si scrive:

```sql
normalize_italian_number("Importo") AS importo,
decode_flag("ETS", 'X') AS flag_ets,
normalize_string("Denominazione") AS denominazione,
```

Le macro sono caricate automaticamente — **non serve importare nulla**.
Dettaglio completo: [docs/standard-macros.md](docs/standard-macros.md).

## Configurazione (`dataset.yml`)

Il cuore del toolkit è un file YAML che descrive il dataset:
Expand Down Expand Up @@ -153,6 +178,7 @@ esegue le trasformazioni SQL su DuckDB e produce output in `root/data/`.
| [advanced-workflows.md](docs/advanced-workflows.md) | Resume, run parziali, profile, debug |
| [notebook-contract.md](docs/notebook-contract.md) | Come leggere gli output nei notebook |
| [feature-stability.md](docs/feature-stability.md) | Cosa è stabile, cosa sperimentale, cosa deprecated |
| [standard-macros.md](docs/standard-macros.md) | Macro SQL predefinite per clean.sql |

### Plugin sorgente supportati

Expand Down
170 changes: 170 additions & 0 deletions docs/standard-macros.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
# Standard SQL Macros

Le **macro SQL** sono funzioni DuckDB precaricate automaticamente in ogni esecuzione del layer CLEAN. Servono a eliminare il boilerplate che oggi è riscritto da zero in ogni `clean.sql` — `TRY_CAST`, `REPLACE` per numeri italiani, `CASE` per flag booleani, `TRIM` per stringhe.

## Come funzionano

Le macro sono definite in `toolkit/sql/macros.sql` (dentro il pacchetto, distribuite via wheel). Quando il toolkit esegue un `clean.sql`, carica automaticamente tutte le macro nella connessione DuckDB prima di eseguire la query. Ogni macro è `CREATE OR REPLACE` — sicura da eseguire più volte.

**Non serve** importare nulla, includere file o modificare `dataset.yml`. Le macro sono sempre disponibili.

## Elenco macro

### `normalize_string(val)`

`TRIM` + stringa vuota → `NULL`. Per colonne testuali.

```sql
-- PRIMA
TRIM(CAST("Denominazione" AS VARCHAR)) AS denominazione,
NULLIF(TRIM("Codice"::VARCHAR), '') AS codice,

-- DOPO
normalize_string("Denominazione") AS denominazione,
normalize_string("Codice") AS codice,
```

### `cast_int(val)`

`TRY_CAST(val AS INTEGER)`. Per colonne numeriche intere (32-bit).

```sql
-- PRIMA
TRY_CAST("Prog" AS INTEGER) AS progressivo,
CAST("Anno" AS INTEGER) AS anno,

-- DOPO
cast_int("Prog") AS progressivo,
cast_int("Anno") AS anno,
```

### `cast_bigint(val)`

`TRY_CAST(val AS BIGINT)`. Per colonne numeriche grandi (64-bit). Usato dallo scaffold per colonne mappate come `int`/`bigint`.

```sql
-- PRIMA
TRY_CAST("Numero contribuenti" AS BIGINT) AS numero_contribuenti,

-- DOPO
cast_bigint("Numero contribuenti") AS numero_contribuenti,
```

### `cast_double(val)`

`TRY_CAST(val AS DOUBLE)`. Per colonne numeriche con decimali.

```sql
-- PRIMA
TRY_CAST(TRIM(CAST("Importo" AS VARCHAR)) AS DOUBLE) AS importo,

-- DOPO
cast_double("Importo") AS importo,
```

### `normalize_italian_number(val)`

Converte un numero in formato italiano (`1.234,56` → `1234.56`). Rimuove punti migliaia, converte virgola decimale in punto. `TRY_CAST` restituisce `NULL` se la conversione fallisce.

```sql
-- PRIMA (15 righe per 4 colonne)
TRY_CAST(REPLACE(REPLACE("Importo"::VARCHAR, '.', ''), ',', '.') AS DOUBLE) AS importo,
TRY_CAST(REPLACE(REPLACE("Numero scelte"::VARCHAR, '.', ''), ',', '.') AS INTEGER) AS numero_scelte,

-- DOPO (2 righe)
normalize_italian_number("Importo") AS importo,
normalize_italian_integer("Numero scelte") AS numero_scelte,
```

### `normalize_italian_integer(val)`

Come `normalize_italian_number` ma restituisce `INTEGER`. DuckDB `CAST(DOUBLE AS INTEGER)` **arrotonda** (non tronca): `5.432,90` → `5433`.

### `decode_flag(val, yes_value)`

Decodifica un flag testuale in `BOOLEAN`. Il secondo argomento è il valore che rappresenta `TRUE`.

```sql
-- PRIMA
CASE WHEN TRIM("ETS") = 'X' THEN TRUE ELSE FALSE END AS flag_ets,

-- DOPO
decode_flag("ETS", 'X') AS flag_ets,
```

### `remove_dot_thousands(val)`

Rimuove punti migliaia da **numeri interi**. Attenzione: rimuove **tutti** i punti, incluso un eventuale separatore decimale standard. Usa solo su interi con punti migliaia. Per numeri con decimali usa `normalize_italian_number` o `cast_double`.

```sql
-- SOLO PER INTERI
remove_dot_thousands("Popolazione") AS popolazione, -- "1.234" → 1234.0

-- NON USARE SU DECIMALI — usa invece:
normalize_italian_number("Importo") AS importo, -- "1.234,56" → 1234.56
cast_double("Valore") AS valore, -- "1234.56" → 1234.56
```

## Esempio completo

Prima (`ade-cinque-per-mille/sql/clean.sql`, 21 righe):

```sql
SELECT
{year}::INTEGER AS anno,
TRY_CAST("Prog" AS INTEGER) AS progressivo,
TRIM("Codice fiscale") AS codice_fiscale,
TRIM("Denominazione") AS denominazione,
TRIM("Regione") AS regione,
TRIM("PR") AS sigla_provincia,
TRIM("Comune") AS comune,
CASE WHEN TRIM("ETS") = 'X' THEN TRUE ELSE FALSE END AS flag_ets_onlus,
CASE WHEN TRIM("ASD") = 'X' THEN TRUE ELSE FALSE END AS flag_asd,
-- ... altri CASE WHEN identici ...
TRY_CAST(REPLACE(REPLACE("Numero scelte"::VARCHAR, '.', ''), ',', '.') AS INTEGER) AS numero_scelte,
TRY_CAST(REPLACE(REPLACE("Importo delle scelte espresse"::VARCHAR, '.', ''), ',', '.') AS DOUBLE) AS importo_scelte_espresse,
FROM raw_input
```

Dopo (18 righe, zero boilerplate — solo logica di dominio):

```sql
SELECT
{year}::INTEGER AS anno,
cast_int("Prog") AS progressivo,
normalize_string("Codice fiscale") AS codice_fiscale,
normalize_string("Denominazione") AS denominazione,
normalize_string("Regione") AS regione,
normalize_string("PR") AS sigla_provincia,
normalize_string("Comune") AS comune,
decode_flag("ETS", 'X') AS flag_ets_onlus,
decode_flag("ASD", 'X') AS flag_asd,
-- ... altri decode_flag identici ...
normalize_italian_integer("Numero scelte") AS numero_scelte,
normalize_italian_number("Importo delle scelte espresse") AS importo_scelte_espresse,
FROM raw_input
```

## Perché macro SQL invece di Python preprocessing?

| Approccio | Dove opera | Pro |
|---|---|---|
| **Macro DuckDB** | Dentro `clean.sql` | Puro SQL, testabile in DuckDB CLI, zero dipendenze Python |
| `normalize.py` (Python) | Prima di DuckDB (script/extractor) | Logica più complessa, gestione encoding, regex rename |

Le macro DuckDB e le funzioni Python in `toolkit/core/normalize.py` sono complementari: `normalize.py` prepara i dati prima che entrino in DuckDB, le macro lavorano dentro DuckDB.

## Verifica

Per testare una macro direttamente:

```bash
python -c "
import duckdb
con = duckdb.connect()
con.execute(open('toolkit/sql/macros.sql').read())
print(con.execute(\"SELECT normalize_italian_number('1.234,56')\").fetchone())
"
```

I test automatici: `pytest tests/test_macros_sql.py -v` (35 test).
39 changes: 14 additions & 25 deletions project-example/sql/clean.sql
Original file line number Diff line number Diff line change
Expand Up @@ -25,31 +25,20 @@ WITH base AS (
)

SELECT
CAST({year} AS INTEGER) AS anno,

CAST(TRIM(regione) AS VARCHAR) AS regione,
CAST(TRIM(provincia) AS VARCHAR) AS provincia,
CAST(TRIM(comune) AS VARCHAR) AS comune,

CAST(
NULLIF(
REPLACE(
REPLACE(
REPLACE(TRIM(CAST(pct_rd_raw AS VARCHAR)), '%', ''),
'.', ''),
',', '.'),
'-')
AS DOUBLE
) AS pct_rd,

CAST(
NULLIF(
REPLACE(
REPLACE(TRIM(CAST(ru_tot_t_raw AS VARCHAR)), '.', ''),
',', '.'),
'-')
AS DOUBLE
) AS ru_tot_t
cast_int({year}) AS anno,

normalize_string(regione) AS regione,
normalize_string(provincia) AS provincia,
normalize_string(comune) AS comune,

-- Il formato italiano (%, . e ,) viene normalizzato dal toolkit:
-- normalize_italian_number gestisce 1.234,56 → 1234.56
-- La % viene rimossa manualmente prima della macro
normalize_italian_number(
REPLACE(normalize_string(pct_rd_raw), '%', '')
) AS pct_rd,

normalize_italian_number(ru_tot_t_raw) AS ru_tot_t

FROM base
WHERE regione IS NOT NULL AND TRIM(regione) <> ''
Expand Down
3 changes: 3 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,9 @@ Documentation = "https://github.com/dataciviclab/toolkit"
include = ["toolkit*"]
exclude = ["tests*"]

[tool.setuptools.package-data]
toolkit = ["sql/*.sql"]

[tool.setuptools.dynamic]
version = { attr = "toolkit.version.__version__" }

Expand Down
16 changes: 8 additions & 8 deletions smoke/bdap_ckan_csv/sql/clean.sql
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
WITH base AS (
SELECT
TRY_CAST(TRIM(CAST("Anno di Riferimento" AS VARCHAR)) AS INTEGER) AS anno,
TRY_CAST(TRIM(CAST("Codice Regione" AS VARCHAR)) AS INTEGER) AS codice_regione,
TRIM(CAST("Descrizione Regione" AS VARCHAR)) AS regione,
TRY_CAST(TRIM(CAST("Codice Ente SSN" AS VARCHAR)) AS INTEGER) AS codice_ente_ssn,
TRIM(CAST("Descrizione Ente" AS VARCHAR)) AS descrizione_ente,
TRIM(CAST("Codice Voce Contabile" AS VARCHAR)) AS codice_voce_contabile,
TRIM(CAST("Descrizione Voce Contabile" AS VARCHAR)) AS descrizione_voce_contabile,
TRY_CAST(TRIM(CAST("Importo Totale" AS VARCHAR)) AS DOUBLE) AS importo_totale
cast_int("Anno di Riferimento") AS anno,
cast_int("Codice Regione") AS codice_regione,
normalize_string("Descrizione Regione") AS regione,
cast_int("Codice Ente SSN") AS codice_ente_ssn,
normalize_string("Descrizione Ente") AS descrizione_ente,
normalize_string("Codice Voce Contabile") AS codice_voce_contabile,
normalize_string("Descrizione Voce Contabile") AS descrizione_voce_contabile,
cast_double("Importo Totale") AS importo_totale
FROM raw_input
)

Expand Down
47 changes: 22 additions & 25 deletions smoke/bdap_http_csv/sql/clean.sql
Original file line number Diff line number Diff line change
@@ -1,30 +1,27 @@
WITH base AS (
SELECT
TRY_CAST(TRIM(CAST("ANNO" AS VARCHAR)) AS INTEGER) AS anno,

TRY_CAST(TRIM(CAST("RISPARMIO_PUBBLICO" AS VARCHAR)) AS DOUBLE) AS risparmio_pubblico,
TRY_CAST(TRIM(CAST("SALDO_NETTO" AS VARCHAR)) AS DOUBLE) AS saldo_netto,
TRY_CAST(TRIM(CAST("INDEBITAMENTO_NETTO" AS VARCHAR)) AS DOUBLE) AS indebitamento_netto,
TRY_CAST(TRIM(CAST("RICORSO_MERCATO" AS VARCHAR)) AS DOUBLE) AS ricorso_mercato,
TRY_CAST(TRIM(CAST("AVANZO_PRIMARIO" AS VARCHAR)) AS DOUBLE) AS avanzo_primario,

TRY_CAST(TRIM(CAST("SPESE_CORRENTI" AS VARCHAR)) AS DOUBLE) AS spese_correnti,
TRY_CAST(TRIM(CAST("SPESE_INTERESSI" AS VARCHAR)) AS DOUBLE) AS spese_interessi,
TRY_CAST(TRIM(CAST("SPESE_CONTO_CAPITALE" AS VARCHAR)) AS DOUBLE) AS spese_conto_capitale,
TRY_CAST(TRIM(CAST("SPESE_ACQ_ATT_FINE" AS VARCHAR)) AS DOUBLE) AS spese_acq_att_fin,
TRY_CAST(TRIM(CAST("SPESE_RIMBORSO_PRESTITI" AS VARCHAR)) AS DOUBLE) AS spese_rimborso_prestiti,
TRY_CAST(TRIM(CAST("SPESE_COMPLESSIVE" AS VARCHAR)) AS DOUBLE) AS spese_complessive,
TRY_CAST(TRIM(CAST("SPESE_FINALI" AS VARCHAR)) AS DOUBLE) AS spese_finali,
TRY_CAST(TRIM(CAST("SPESE_FIN_NETTO_ATT_FIN" AS VARCHAR)) AS DOUBLE) AS spese_fin_netto_att_fin,

TRY_CAST(TRIM(CAST("ENTRATE_TRIBUTARIE" AS VARCHAR)) AS DOUBLE) AS entrate_tributarie,
TRY_CAST(TRIM(CAST("ENTRATE_EXTRA_TRIBUTARIE" AS VARCHAR)) AS DOUBLE) AS entrate_extra_tributarie,
TRY_CAST(TRIM(CAST("ENTR_ALIEN_PATR_RISCOS" AS VARCHAR)) AS DOUBLE) AS entr_alien_patr_riscos,
TRY_CAST(TRIM(CAST("RISCOSSIONE_CREDITI" AS VARCHAR)) AS DOUBLE) AS riscossione_crediti,
TRY_CAST(TRIM(CAST("ENTR_ACCENSIONE_PRESTITI" AS VARCHAR)) AS DOUBLE) AS entr_accensione_prestiti,
TRY_CAST(TRIM(CAST("ENTRATE_FINALI" AS VARCHAR)) AS DOUBLE) AS entrate_finali,
TRY_CAST(TRIM(CAST("ENTR_FIN_NETTO_RISCO_CRED" AS VARCHAR)) AS DOUBLE) AS entr_fin_netto_risco_cred,
TRY_CAST(TRIM(CAST("ENTRATE_CORRENTI" AS VARCHAR)) AS DOUBLE) AS entrate_correnti
cast_int("ANNO") AS anno,
cast_double("RISPARMIO_PUBBLICO") AS risparmio_pubblico,
cast_double("SALDO_NETTO") AS saldo_netto,
cast_double("INDEBITAMENTO_NETTO") AS indebitamento_netto,
cast_double("RICORSO_MERCATO") AS ricorso_mercato,
cast_double("AVANZO_PRIMARIO") AS avanzo_primario,
cast_double("SPESE_CORRENTI") AS spese_correnti,
cast_double("SPESE_INTERESSI") AS spese_interessi,
cast_double("SPESE_CONTO_CAPITALE") AS spese_conto_capitale,
cast_double("SPESE_ACQ_ATT_FINE") AS spese_acq_att_fin,
cast_double("SPESE_RIMBORSO_PRESTITI") AS spese_rimborso_prestiti,
cast_double("SPESE_COMPLESSIVE") AS spese_complessive,
cast_double("SPESE_FINALI") AS spese_finali,
cast_double("SPESE_FIN_NETTO_ATT_FIN") AS spese_fin_netto_att_fin,
cast_double("ENTRATE_TRIBUTARIE") AS entrate_tributarie,
cast_double("ENTRATE_EXTRA_TRIBUTARIE") AS entrate_extra_tributarie,
cast_double("ENTR_ALIEN_PATR_RISCOS") AS entr_alien_patr_riscos,
cast_double("RISCOSSIONE_CREDITI") AS riscossione_crediti,
cast_double("ENTR_ACCENSIONE_PRESTITI") AS entr_accensione_prestiti,
cast_double("ENTRATE_FINALI") AS entrate_finali,
cast_double("ENTR_FIN_NETTO_RISCO_CRED") AS entr_fin_netto_risco_cred,
cast_double("ENTRATE_CORRENTI") AS entrate_correnti
FROM raw_input
)

Expand Down
14 changes: 7 additions & 7 deletions smoke/finanze_http_zip_2023/sql/clean.sql
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
SELECT
TRY_CAST("Anno di imposta" AS INTEGER) AS anno_imposta,
"Codice catastale" AS codice_catastale,
"Codice Istat Comune" AS codice_istat_comune,
"Denominazione Comune" AS comune,
"Sigla Provincia" AS sigla_provincia,
"Regione" AS regione,
TRY_CAST("Numero contribuenti" AS BIGINT) AS numero_contribuenti
cast_int("Anno di imposta") AS anno_imposta,
normalize_string("Codice catastale") AS codice_catastale,
normalize_string("Codice Istat Comune") AS codice_istat_comune,
normalize_string("Denominazione Comune") AS comune,
normalize_string("Sigla Provincia") AS sigla_provincia,
normalize_string("Regione") AS regione,
cast_bigint("Numero contribuenti") AS numero_contribuenti
FROM raw_input
6 changes: 3 additions & 3 deletions smoke/local_file_csv/sql/clean.sql
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
SELECT
CAST(anno AS INTEGER) AS anno,
cast_int(anno) AS anno,
comune,
provincia,
regione,
CAST(codice_comune AS INTEGER) AS codice_comune,
cast_int(codice_comune) AS codice_comune,
categoria,
CAST(valore AS DOUBLE) AS valore
cast_double(valore) AS valore
FROM raw_input
Loading
Loading