From 74b1e1ade991719db1759ec8ceb913508571f296 Mon Sep 17 00:00:00 2001 From: Zio Gabber <78922322+Gabrymi93@users.noreply.github.com> Date: Thu, 25 Jun 2026 20:18:51 +0100 Subject: [PATCH 1/2] feat(scaffold): scaffolding CKAN via DataStore schema (#363) --- tests/test_scaffold_clean.py | 115 ++++++++++++++++++++++ tests/test_scout_datastore.py | 179 ++++++++++++++++++++++++++++++++++ toolkit/cli/cmd_scout.py | 111 ++++++++++----------- toolkit/scaffold/clean.py | 48 +++++++++ toolkit/scout/__init__.py | 1 + toolkit/scout/http.py | 65 +++++++++++- 6 files changed, 461 insertions(+), 58 deletions(-) create mode 100644 tests/test_scout_datastore.py diff --git a/tests/test_scaffold_clean.py b/tests/test_scaffold_clean.py index f3534fc..4e8ebe4 100644 --- a/tests/test_scaffold_clean.py +++ b/tests/test_scaffold_clean.py @@ -474,3 +474,118 @@ def test_varchar_gets_trim(self) -> None: assert 'trim(CAST("nome" AS VARCHAR))' in sql assert 'trim(CAST("categoria" AS VARCHAR))' in sql assert 'TRY_CAST("valore" AS DOUBLE)' in sql + + +# --------------------------------------------------------------------------- +# pure_unit: _map_datastore_type +# --------------------------------------------------------------------------- + + +class TestMapDatastoreType: + """pure_unit: _map_datastore_type traduce tipi DataStore → DuckDB.""" + + @pytest.mark.pure_unit + @pytest.mark.parametrize( + "ds_type,expected", + [ + ("numeric", "BIGINT"), + ("int", "BIGINT"), + ("integer", "BIGINT"), + ("bigint", "BIGINT"), + ("smallint", "BIGINT"), + ("tinyint", "BIGINT"), + ("float", "DOUBLE"), + ("double", "DOUBLE"), + ("real", "DOUBLE"), + ("number", "DOUBLE"), + ("text", "VARCHAR"), + ("json", "VARCHAR"), + ("array", "VARCHAR"), + ("timestamp", "DATE"), + ("date", "DATE"), + ("datetime", "DATE"), + ("bool", "BOOLEAN"), + ("boolean", "BOOLEAN"), + ("unknown_type", "VARCHAR"), + ("", "VARCHAR"), + ], + ) + def test_map_datastore_type(self, ds_type: str, expected: str) -> None: + from toolkit.scaffold.clean import _map_datastore_type + + assert _map_datastore_type(ds_type) == expected + + +# --------------------------------------------------------------------------- +# pure_unit: profile_from_datastore +# --------------------------------------------------------------------------- + + +class TestProfileFromDatastore: + """pure_unit: profile_from_datastore costruisce profile da fields CKAN DataStore.""" + + @pytest.mark.pure_unit + def test_empty_fields(self) -> None: + """Lista vuota → mapping_suggestions vuoto, columns_raw vuoto.""" + from toolkit.scaffold.clean import profile_from_datastore + + result = profile_from_datastore([]) + assert result == {"mapping_suggestions": {}, "columns_raw": []} + + @pytest.mark.pure_unit + def test_single_text_field(self) -> None: + """Campo text → mapping con VARCHAR, columns_raw popolato.""" + from toolkit.scaffold.clean import profile_from_datastore + + fields = [ + {"id": "nome", "type": "text", "info": {"label": "Nome", "notes": "Il nome"}}, + ] + result = profile_from_datastore(fields) + assert result["mapping_suggestions"]["nome"]["type"] == "VARCHAR" + assert result["columns_raw"] == ["nome"] + + @pytest.mark.pure_unit + def test_mixed_types(self) -> None: + """Tipi misti → mapping corretto per ogni campo.""" + from toolkit.scaffold.clean import profile_from_datastore + + fields = [ + {"id": "id_lista", "type": "numeric"}, + {"id": "denominazione", "type": "text"}, + {"id": "sezione", "type": "numeric"}, + {"id": "data_udienza", "type": "timestamp"}, + {"id": "importo", "type": "float"}, + {"id": "attivo", "type": "bool"}, + ] + result = profile_from_datastore(fields) + assert result["mapping_suggestions"]["id_lista"]["type"] == "BIGINT" + assert result["mapping_suggestions"]["denominazione"]["type"] == "VARCHAR" + assert result["mapping_suggestions"]["sezione"]["type"] == "BIGINT" + assert result["mapping_suggestions"]["data_udienza"]["type"] == "DATE" + assert result["mapping_suggestions"]["importo"]["type"] == "DOUBLE" + assert result["mapping_suggestions"]["attivo"]["type"] == "BOOLEAN" + assert result["columns_raw"] == [ + "id_lista", + "denominazione", + "sezione", + "data_udienza", + "importo", + "attivo", + ] + + @pytest.mark.pure_unit + def test_generate_clean_sql_from_datastore(self) -> None: + """Profile da DataStore produce clean.sql con colonne mappate.""" + from toolkit.scaffold.clean import generate_clean_sql, profile_from_datastore + + fields = [ + {"id": "nome", "type": "text"}, + {"id": "valore", "type": "numeric"}, + {"id": "data", "type": "date"}, + ] + profile = profile_from_datastore(fields) + sql = generate_clean_sql(profile, "test_dataset", 2024) + assert 'trim(CAST("nome" AS VARCHAR)) AS nome' in sql + assert 'TRY_CAST("valore" AS BIGINT) AS valore' in sql + assert 'TRY_CAST("data" AS DATE) AS data' in sql + assert "{year}::INTEGER AS anno" in sql diff --git a/tests/test_scout_datastore.py b/tests/test_scout_datastore.py new file mode 100644 index 0000000..e2a88d7 --- /dev/null +++ b/tests/test_scout_datastore.py @@ -0,0 +1,179 @@ +"""Tests per toolkit.scout.http — fetch_ckan_datastore_schema e discover_ckan_resources.""" + +from __future__ import annotations + +from unittest.mock import MagicMock + +import pytest + +from toolkit.scout.http import discover_ckan_resources + +pytestmark = pytest.mark.contract + + +def _mock_http(fields: list | None = None, status: int = 200, success: bool = True) -> MagicMock: + """Crea un HttpClient.mock con risposta DataStore.""" + client = MagicMock() + resp = MagicMock() + resp.status_code = status + resp.json.return_value = {"success": success, "result": {"fields": fields or [], "total": 0}} + result = MagicMock() + result.is_ok = True + result.response = resp + client.get.return_value = result + return client + + +MOCK_FIELDS = [ + {"id": "nome", "type": "text", "info": {"label": "Nome"}}, + {"id": "valore", "type": "numeric"}, + {"id": "data", "type": "date"}, +] + + +# --------------------------------------------------------------------------- +# contract: discover_ckan_resources — campo datastore_active +# --------------------------------------------------------------------------- + + +class TestDiscoverCkanResourcesDatastoreActive: + """contract: discover_ckan_resources include datastore_active.""" + + @pytest.mark.parametrize( + "pkg,expected_active,expected_count", + [ + ( + { + "resources": [ + { + "id": "r1", + "name": "t.csv", + "format": "CSV", + "url": "https://e.t/t.csv", + "datastore_active": True, + } + ] + }, + True, + 1, + ), + ( + { + "resources": [ + { + "id": "r1", + "name": "t.csv", + "format": "CSV", + "url": "https://e.t/t.csv", + "datastore_active": False, + } + ] + }, + False, + 1, + ), + ( + { + "resources": [ + {"id": "r1", "name": "t.csv", "format": "CSV", "url": "https://e.t/t.csv"} + ] + }, + False, + 1, + ), + ( + { + "resources": [ + { + "id": "r1", + "name": "t.pdf", + "format": "PDF", + "url": "", + "datastore_active": True, + } + ] + }, + None, + 0, + ), + ], + ) + def test_datastore_active_flag(self, pkg, expected_active, expected_count) -> None: + resources = discover_ckan_resources(pkg) + assert len(resources) == expected_count + if expected_count: + assert resources[0]["datastore_active"] is expected_active + + def test_backward_compat_fields(self) -> None: + """Campi legacy (id, name, format, url) ancora presenti.""" + pkg = { + "resources": [ + { + "id": "abc", + "name": "t.csv", + "format": "CSV", + "url": "https://e.t/t.csv", + "datastore_active": True, + } + ] + } + r = discover_ckan_resources(pkg)[0] + assert r == { + "id": "abc", + "name": "t.csv", + "format": "csv", + "url": "https://e.t/t.csv", + "datastore_active": True, + } + + +# --------------------------------------------------------------------------- +# contract: fetch_ckan_datastore_schema +# --------------------------------------------------------------------------- + + +class TestFetchCkanDatastoreSchema: + """contract: fetch_ckan_datastore_schema torna fields o None.""" + + def test_returns_fields(self) -> None: + from toolkit.scout.http import fetch_ckan_datastore_schema + + result = fetch_ckan_datastore_schema( + "https://example.test", "r1", client=_mock_http(MOCK_FIELDS) + ) + assert result is not None + assert [f["id"] for f in result] == ["nome", "valore", "data"] + + def test_filters_id_field(self) -> None: + from toolkit.scout.http import fetch_ckan_datastore_schema + + result = fetch_ckan_datastore_schema( + "https://example.test", + "r1", + client=_mock_http(MOCK_FIELDS + [{"id": "_id", "type": "int"}]), + ) + assert result is not None + assert "_id" not in {f["id"] for f in result} + assert len(result) == 3 + + @pytest.mark.parametrize( + "client,reason", + [ + (_mock_http(success=False), "success=False"), + (_mock_http(status=500), "HTTP 500"), + (_mock_http([]), "empty fields"), + ], + ) + def test_error_returns_none(self, client, reason) -> None: + from toolkit.scout.http import fetch_ckan_datastore_schema + + result = fetch_ckan_datastore_schema("https://example.test", "r1", client=client) + assert result is None, f"atteso None per {reason}" + + def test_normalizes_portal_url(self) -> None: + from toolkit.scout.http import fetch_ckan_datastore_schema + + client = _mock_http(MOCK_FIELDS) + fetch_ckan_datastore_schema("https://example.test/dataset/foo", "r1", client=client) + called_url = client.get.call_args[0][0] + assert called_url.startswith("https://example.test/api/3/action/") diff --git a/toolkit/cli/cmd_scout.py b/toolkit/cli/cmd_scout.py index b4ae99c..bb70d39 100644 --- a/toolkit/cli/cmd_scout.py +++ b/toolkit/cli/cmd_scout.py @@ -16,7 +16,6 @@ import tempfile import uuid from pathlib import Path -from urllib.parse import urlparse from typing import Any import typer @@ -353,6 +352,20 @@ def _scaffold_file( typer.echo(f"\nNext: toolkit run all --config {out_dir / 'dataset.yml'}") +def _write_scaffold_files( + slug: str, + scaffold_files: dict[str, str], +) -> Path: + """Scrive su disco i file restituiti da ``generate_full_scaffold``.""" + out_dir = Path(slug) + out_dir.mkdir(parents=True, exist_ok=True) + for fname, content in scaffold_files.items(): + fpath = out_dir / fname + fpath.parent.mkdir(parents=True, exist_ok=True) + fpath.write_text(content, encoding="utf-8") + return out_dir + + def _scaffold_ckan( url: str, probe_result: dict[str, Any], @@ -360,83 +373,67 @@ def _scaffold_ckan( run_raw: bool = False, slug: str | None = None, ) -> None: - """Scaffold per risorsa CKAN.""" + """Scaffold per risorsa CKAN. + + Tenta nell'ordine: + 1. **DataStore schema** (se ``datastore_active=True``) — colonne e tipi + via ``datastore_search?limit=0``, nessun download. + 2. **Download + profiling CSV** (comportamento attuale). + 3. **Scaffold minimale** via ``generate_full_scaffold`` (fallback). + """ resources = probe_result.get("ckan_resources") or [] if not resources: typer.echo("error: no CKAN resources available", err=True) raise typer.Exit(code=1) + # Prova DataStore se la prima risorsa lo supporta + if resources[0].get("datastore_active"): + first = resources[0] + slug = slug or slugify(url) + try: + from toolkit.scout.http import fetch_ckan_datastore_schema + from toolkit.scaffold.clean import profile_from_datastore + + fields = fetch_ckan_datastore_schema(url, first["id"]) + if fields: + profile = profile_from_datastore(fields) + scaffold_files = generate_full_scaffold( + slug, + probe_result, + profile=profile, + inferred_years=[2024], + ) + out_dir = _write_scaffold_files(slug, scaffold_files) + + typer.echo(f"\nCKAN DataStore scaffold generated: {out_dir / 'dataset.yml'}") + typer.echo(f" clean.sql with {len(fields)} columns from DataStore schema") + return + except Exception: + typer.echo(" DataStore schema fetch failed, trying CSV profiling...") + first_url = resources[0]["url"] try: _scaffold_file(first_url, probe_result, run_raw=run_raw, slug=slug) return except (typer.Exit, Exception): typer.echo(" Warning: profiling failed for resource, generating minimal scaffold") - _scaffold_minimal_ckan(url, probe_result, resources, run_raw=run_raw, slug=slug) + _scaffold_minimal_ckan(url, probe_result, slug=slug) def _scaffold_minimal_ckan( url: str, probe_result: dict[str, Any], - resources: list[dict[str, Any]], *, - run_raw: bool = False, slug: str | None = None, ) -> None: - """Genera scaffold minimale CKAN quando il profiling fallisce.""" - from toolkit.scaffold.sources import block_ckan + """Genera scaffold minimale CKAN quando il profiling fallisce. + Delega a ``generate_full_scaffold`` senza profile per ottenere + dataset.yml, clean.sql placeholder e mart.sql skeleton. + """ slug = slug or slugify(url) - parsed = urlparse(url) - portal_base = f"{parsed.scheme}://{parsed.netloc}" - source_lines = block_ckan(resources, portal_base) - - lines = [ - "# Auto-generated by toolkit (minimal CKAN scaffold)", - "# Profiling failed for the first resource.", - "# Complete clean.read and clean.sql manually before running.", - "", - 'root: "../../out"', - "schema_version: 1", - "", - "dataset:", - f' name: "{slug}"', - " years: [2024]", - "", - "raw:", - " output_policy: overwrite", - " sources:", - ] - lines.extend(source_lines) - lines.append("") - lines.append("clean:") - lines.append(' sql: "sql/clean.sql"') - lines.append("") - lines.append("mart:") - lines.append(" tables:") - lines.append(f' - name: "{slug}"') - lines.append(' sql: "sql/mart.sql"') - - out_dir = Path(slug) - out_dir.mkdir(parents=True, exist_ok=True) - (out_dir / "dataset.yml").write_text("\n".join(lines) + "\n", encoding="utf-8") - - for sub in ["sql", "notebooks"]: - (out_dir / sub).mkdir(exist_ok=True) - (out_dir / "sql/clean.sql").write_text( - "-- Minimal CKAN scaffold. Personalizza colonne e trasformazioni.\n" - "SELECT * FROM raw_input\n", - encoding="utf-8", - ) - (out_dir / "sql/mart.sql").write_text( - "-- Default mart: SELECT * FROM clean_input.\nSELECT * FROM clean_input\n", - encoding="utf-8", - ) - (out_dir / "README.md").write_text( - f"# {slug}\n\nFonte: {url}\n\n## Stato\n\n- intake (minimal scaffold)\n", - encoding="utf-8", - ) - + scaffold_files = generate_full_scaffold(slug, probe_result) + out_dir = _write_scaffold_files(slug, scaffold_files) typer.echo(f"\nMinimal dataset YAML generated: {out_dir / 'dataset.yml'}") typer.echo(" clean.read, clean.sql, mart.sql: manual editing required") diff --git a/toolkit/scaffold/clean.py b/toolkit/scaffold/clean.py index 4384ce4..1f5fb72 100644 --- a/toolkit/scaffold/clean.py +++ b/toolkit/scaffold/clean.py @@ -50,6 +50,54 @@ def _map_duckdb_type(raw_type: str) -> str: return "VARCHAR" +def _map_datastore_type(ds_type: str) -> str: + """Map CKAN DataStore type to DuckDB SQL type. + + DataStore types (https://docs.ckan.org/en/latest/maintaining/datastore.html): + ``numeric``, ``int``, ``integer``, ``bigint`` → ``BIGINT`` + ``float``, ``double``, ``real`` → ``DOUBLE`` + ``text`` → ``VARCHAR`` + ``timestamp``, ``date`` → ``DATE`` + ``bool``, ``boolean`` → ``BOOLEAN`` + """ + dt = ds_type.strip().lower() + if dt in ("numeric", "int", "integer", "bigint", "smallint", "tinyint"): + return "BIGINT" + if dt in ("float", "double", "real", "number"): + return "DOUBLE" + if dt in ("timestamp", "date", "datetime"): + return "DATE" + if dt in ("bool", "boolean"): + return "BOOLEAN" + return "VARCHAR" # text, json, array, e unknown + + +def profile_from_datastore(fields: list[dict[str, Any]]) -> dict[str, Any]: + """Costruisce un profile dict compatibile con ``generate_clean_sql()`` + a partire dai fields restituiti da ``datastore_search?limit=0``. + + Args: + fields: Lista di dict CKAN DataStore con ``id``, ``type``, + opzionalmente ``info.label`` e ``info.notes``. + + Returns: + Profile dict con ``mapping_suggestions`` utilizzabile da + ``generate_clean_sql()`` e ``generate_full_scaffold()``. + """ + mapping: dict[str, dict[str, str]] = {} + for f in fields: + col = f.get("id", "") + if not col: + continue + ds_type = f.get("type", "text") + sql_type = _map_datastore_type(ds_type) + mapping[col] = {"type": sql_type} + return { + "mapping_suggestions": mapping, + "columns_raw": [f["id"] for f in fields if f.get("id")], + } + + # Formati data organizzati per gruppo con stesso separatore. # I gruppi con due formati (DMY/MDY) richiedono disambiguazione: # valori che parsano in UN SOLO formato votano quello; valori che diff --git a/toolkit/scout/__init__.py b/toolkit/scout/__init__.py index ef70323..50b8421 100644 --- a/toolkit/scout/__init__.py +++ b/toolkit/scout/__init__.py @@ -27,6 +27,7 @@ fetch_content, fetch_html_body, fetch_ckan_package, + fetch_ckan_datastore_schema, discover_ckan_resources, fetch_sdmx_years, is_html_content, diff --git a/toolkit/scout/http.py b/toolkit/scout/http.py index d6af7f0..d18c1af 100644 --- a/toolkit/scout/http.py +++ b/toolkit/scout/http.py @@ -616,6 +616,63 @@ def fetch_ckan_package( return None +def fetch_ckan_datastore_schema( + portal_url: str, + resource_id: str, + *, + timeout: int = DEFAULT_TIMEOUT, + client: HttpClient | None = None, +) -> list[dict[str, Any]] | None: + """Fetch CKAN DataStore schema per una risorsa (``datastore_search?limit=0``). + + Restituisce la lista campi ``[{id, type, info: {label, notes}}, ...]`` + che lo scaffold puo' usare per generare colonne mappate senza scaricare il CSV. + + Args: + portal_url: URL del portale CKAN (qualsiasi forma). + resource_id: UUID della risorsa. + timeout: Timeout HTTP. + client: HttpClient opzionale. + + Returns: + Lista di dict con ``id``, ``type``, ``info``, oppure ``None`` + se la risorsa non ha DataStore o la chiamata fallisce. + """ + parsed = urlparse(portal_url) + root = f"{parsed.scheme}://{parsed.netloc}" + portal_base = _ckan_portal_base(portal_url) + + api_bases: list[str] = [ + f"{portal_base}/api/3/action/datastore_search", + ] + api_base_root = f"{root}/api/3/action/datastore_search" + if api_base_root != api_bases[0]: + api_bases.append(api_base_root) + api_bases.append(f"{root}/datastore_search") + + client = client or _mk_client(timeout=timeout) + for api_base in api_bases: + ds_url = f"{api_base}?resource_id={resource_id}&limit=0" + try: + result = client.get(ds_url) + if not result.is_ok or result.response is None: + continue + resp = result.response + if resp.status_code != 200: + continue + data = resp.json() + if not data.get("success"): + continue + fields = data.get("result", {}).get("fields", []) + if not fields: + continue + # Filtra il campo _id (PK autoincrement interno di CKAN) + return [f for f in fields if f.get("id") != "_id"] + except Exception: + continue + return None + + def search_ckan_datasets( portal_url: str, query: str = "*:*", @@ -735,7 +792,12 @@ def list_sdmx_dataflows( def discover_ckan_resources(pkg: dict[str, Any]) -> list[dict[str, Any]]: - """Estrae risorse scaricabili da un package CKAN.""" + """Estrae risorse scaricabili da un package CKAN. + + ``datastore_active`` indica se la risorsa ha DataStore abilitato: + lo scaffold puo' usare ``datastore_search?limit=0`` invece di scaricare + il CSV per ottenere schema colonne e tipi. + """ resources: list[dict[str, Any]] = pkg.get("resources") or [] discovered: list[dict[str, Any]] = [] for res in resources: @@ -748,6 +810,7 @@ def discover_ckan_resources(pkg: dict[str, Any]) -> list[dict[str, Any]]: "name": res.get("name") or res.get("description") or res.get("id") or "", "format": (res.get("format") or "").lower(), "url": res_url, + "datastore_active": bool(res.get("datastore_active")), } ) return discovered From 047ed7152d60c13841f733f085fca87e12a25aed Mon Sep 17 00:00:00 2001 From: Zio Gabber <78922322+Gabrymi93@users.noreply.github.com> Date: Thu, 25 Jun 2026 20:32:00 +0100 Subject: [PATCH 2/2] fix: numeric data type in DataStore va mappato a DOUBLE non BIGINT MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CKAN DataStore numeric = PostgreSQL NUMERIC(p,s) che ammette decimali. TRY_CAST(x AS BIGINT) tronca 12.34 a 12 — perdita dati silenziosa. TRY_CAST(x AS DOUBLE) mantiene 12.34. Fix in _map_datastore_type e test relativi. --- tests/test_scaffold_clean.py | 8 ++++---- toolkit/scaffold/clean.py | 6 ++++-- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/tests/test_scaffold_clean.py b/tests/test_scaffold_clean.py index 4e8ebe4..79b3cee 100644 --- a/tests/test_scaffold_clean.py +++ b/tests/test_scaffold_clean.py @@ -488,7 +488,7 @@ class TestMapDatastoreType: @pytest.mark.parametrize( "ds_type,expected", [ - ("numeric", "BIGINT"), + ("numeric", "DOUBLE"), ("int", "BIGINT"), ("integer", "BIGINT"), ("bigint", "BIGINT"), @@ -558,9 +558,9 @@ def test_mixed_types(self) -> None: {"id": "attivo", "type": "bool"}, ] result = profile_from_datastore(fields) - assert result["mapping_suggestions"]["id_lista"]["type"] == "BIGINT" + assert result["mapping_suggestions"]["id_lista"]["type"] == "DOUBLE" assert result["mapping_suggestions"]["denominazione"]["type"] == "VARCHAR" - assert result["mapping_suggestions"]["sezione"]["type"] == "BIGINT" + assert result["mapping_suggestions"]["sezione"]["type"] == "DOUBLE" assert result["mapping_suggestions"]["data_udienza"]["type"] == "DATE" assert result["mapping_suggestions"]["importo"]["type"] == "DOUBLE" assert result["mapping_suggestions"]["attivo"]["type"] == "BOOLEAN" @@ -586,6 +586,6 @@ def test_generate_clean_sql_from_datastore(self) -> None: profile = profile_from_datastore(fields) sql = generate_clean_sql(profile, "test_dataset", 2024) assert 'trim(CAST("nome" AS VARCHAR)) AS nome' in sql - assert 'TRY_CAST("valore" AS BIGINT) AS valore' in sql + assert 'TRY_CAST("valore" AS DOUBLE) AS valore' in sql assert 'TRY_CAST("data" AS DATE) AS data' in sql assert "{year}::INTEGER AS anno" in sql diff --git a/toolkit/scaffold/clean.py b/toolkit/scaffold/clean.py index 1f5fb72..0ac10a2 100644 --- a/toolkit/scaffold/clean.py +++ b/toolkit/scaffold/clean.py @@ -61,9 +61,11 @@ def _map_datastore_type(ds_type: str) -> str: ``bool``, ``boolean`` → ``BOOLEAN`` """ dt = ds_type.strip().lower() - if dt in ("numeric", "int", "integer", "bigint", "smallint", "tinyint"): + if dt in ("int", "integer", "bigint", "smallint", "tinyint"): return "BIGINT" - if dt in ("float", "double", "real", "number"): + # numeric in CKAN/PostgreSQL ammette decimali (NUMERIC(p,s)), + # quindi va mappato a DOUBLE per evitare troncamenti silenziosi. + if dt in ("numeric", "float", "double", "real", "number"): return "DOUBLE" if dt in ("timestamp", "date", "datetime"): return "DATE"