sidra-sql 1.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- sidra_sql/__init__.py +15 -0
- sidra_sql/cli.py +631 -0
- sidra_sql/config.py +137 -0
- sidra_sql/database.py +793 -0
- sidra_sql/exporter.py +171 -0
- sidra_sql/models.py +230 -0
- sidra_sql/plugin_manager.py +200 -0
- sidra_sql/runner.py +76 -0
- sidra_sql/scaffold.py +248 -0
- sidra_sql/sidra.py +362 -0
- sidra_sql/storage.py +156 -0
- sidra_sql/toml_runner.py +374 -0
- sidra_sql/transform_runner.py +153 -0
- sidra_sql/utils.py +106 -0
- sidra_sql/validator.py +222 -0
- sidra_sql-1.3.0.dist-info/METADATA +629 -0
- sidra_sql-1.3.0.dist-info/RECORD +20 -0
- sidra_sql-1.3.0.dist-info/WHEEL +4 -0
- sidra_sql-1.3.0.dist-info/entry_points.txt +2 -0
- sidra_sql-1.3.0.dist-info/licenses/LICENSE +21 -0
sidra_sql/database.py
ADDED
|
@@ -0,0 +1,793 @@
|
|
|
1
|
+
"""Database helpers: engine creation and data-loading functions.
|
|
2
|
+
|
|
3
|
+
Public functions:
|
|
4
|
+
- `get_engine`: create a SQLAlchemy engine from `Config`.
|
|
5
|
+
- `save_agregado`: upsert SIDRA table metadata, periods, and localidades.
|
|
6
|
+
- `build_localidade_lookup`: query localidade IDs by (nc, d1c) keys.
|
|
7
|
+
- `build_dimensao_lookup`: query dimensao IDs by dimension key tuples.
|
|
8
|
+
- `build_periodo_lookup`: query periodo IDs by (codigo, literals) keys.
|
|
9
|
+
- `load_dados`: load data rows into the dados table (also upserts
|
|
10
|
+
localidades and dimensions).
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import itertools
|
|
14
|
+
import json
|
|
15
|
+
import logging
|
|
16
|
+
from collections.abc import Callable, Iterable
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
import sqlalchemy as sa
|
|
20
|
+
from sidra_fetcher.agregados import Agregado
|
|
21
|
+
from sidra_fetcher.periodos import expected_periodo_frequencias
|
|
22
|
+
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
|
23
|
+
|
|
24
|
+
from . import models
|
|
25
|
+
from .config import Config
|
|
26
|
+
from .storage import Storage
|
|
27
|
+
|
|
28
|
+
logger = logging.getLogger(__name__)
|
|
29
|
+
|
|
30
|
+
_BATCH_SIZE = 5000
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
# ---------------------------------------------------------------------------
|
|
34
|
+
# Internal helpers
|
|
35
|
+
# ---------------------------------------------------------------------------
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _coerce(val) -> str | None:
|
|
39
|
+
"""Return str(val), or None if val is None."""
|
|
40
|
+
return str(val) if val is not None else None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _clean_str(val) -> str:
|
|
44
|
+
"""Normalize a territory/locality code: strip and remove trailing .0."""
|
|
45
|
+
if val is None:
|
|
46
|
+
return ""
|
|
47
|
+
s = str(val).strip()
|
|
48
|
+
return s[:-2] if s.endswith(".0") else s
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _normalize_nc(nc: str) -> str:
|
|
52
|
+
"""Ensure NC uses the 'N<n>' format (e.g. '6' -> 'N6', 'N6' -> 'N6')."""
|
|
53
|
+
if nc and not nc.startswith("N"):
|
|
54
|
+
return "N" + nc
|
|
55
|
+
return nc
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# ---------------------------------------------------------------------------
|
|
59
|
+
# Engine
|
|
60
|
+
# ---------------------------------------------------------------------------
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def get_engine(config: Config) -> sa.engine.Engine:
|
|
64
|
+
"""Create and return a SQLAlchemy engine for the configured DB."""
|
|
65
|
+
connection_string = (
|
|
66
|
+
f"postgresql+psycopg://{config.db_user}:{config.db_password}"
|
|
67
|
+
f"@{config.db_host}:{config.db_port}/{config.db_name}"
|
|
68
|
+
)
|
|
69
|
+
return sa.create_engine(
|
|
70
|
+
connection_string,
|
|
71
|
+
connect_args={"options": f"-c search_path={config.db_schema}"},
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
# ---------------------------------------------------------------------------
|
|
76
|
+
# Metadata
|
|
77
|
+
# ---------------------------------------------------------------------------
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def save_agregado(engine: sa.engine.Engine, agregado: Agregado):
|
|
81
|
+
"""Save SIDRA table metadata, periods, and localidades to the database.
|
|
82
|
+
|
|
83
|
+
Idempotent.
|
|
84
|
+
"""
|
|
85
|
+
|
|
86
|
+
tabela_sidra = dict(
|
|
87
|
+
id=str(agregado.id),
|
|
88
|
+
nome=agregado.nome,
|
|
89
|
+
periodicidade=agregado.periodicidade.frequencia,
|
|
90
|
+
metadados=json.loads(json.dumps(agregado.asdict(), default=str)),
|
|
91
|
+
)
|
|
92
|
+
with engine.connect() as conn:
|
|
93
|
+
stmt = pg_insert(models.TabelaSidra.__table__).values(tabela_sidra)
|
|
94
|
+
|
|
95
|
+
stmt = stmt.on_conflict_do_update(
|
|
96
|
+
index_elements=["id"],
|
|
97
|
+
set_={"metadados": stmt.excluded.metadados},
|
|
98
|
+
)
|
|
99
|
+
conn.execute(stmt)
|
|
100
|
+
conn.commit()
|
|
101
|
+
|
|
102
|
+
# Save periods
|
|
103
|
+
periodos_iter = (
|
|
104
|
+
dict(
|
|
105
|
+
codigo=periodo.id,
|
|
106
|
+
literals=periodo.literals,
|
|
107
|
+
frequencia=periodo.frequencia,
|
|
108
|
+
data_inicio=periodo.data_inicio if periodo.data_inicio else None,
|
|
109
|
+
data_fim=periodo.data_fim if periodo.data_fim else None,
|
|
110
|
+
ano=periodo.ano,
|
|
111
|
+
ano_fim=periodo.ano_fim,
|
|
112
|
+
semestre=periodo.semestre,
|
|
113
|
+
trimestre=periodo.trimestre,
|
|
114
|
+
mes=periodo.mes,
|
|
115
|
+
)
|
|
116
|
+
for periodo in agregado.periodos
|
|
117
|
+
)
|
|
118
|
+
with engine.connect() as conn:
|
|
119
|
+
while True:
|
|
120
|
+
batch = list(itertools.islice(periodos_iter, _BATCH_SIZE))
|
|
121
|
+
if not batch:
|
|
122
|
+
break
|
|
123
|
+
stmt = pg_insert(models.Periodo.__table__).values(batch)
|
|
124
|
+
stmt = stmt.on_conflict_do_update(
|
|
125
|
+
constraint="uq_periodo",
|
|
126
|
+
set_={
|
|
127
|
+
"frequencia": stmt.excluded.frequencia,
|
|
128
|
+
"data_inicio": stmt.excluded.data_inicio,
|
|
129
|
+
"data_fim": stmt.excluded.data_fim,
|
|
130
|
+
"ano": stmt.excluded.ano,
|
|
131
|
+
"ano_fim": stmt.excluded.ano_fim,
|
|
132
|
+
"semestre": stmt.excluded.semestre,
|
|
133
|
+
"trimestre": stmt.excluded.trimestre,
|
|
134
|
+
"mes": stmt.excluded.mes,
|
|
135
|
+
},
|
|
136
|
+
)
|
|
137
|
+
conn.execute(stmt)
|
|
138
|
+
conn.commit()
|
|
139
|
+
|
|
140
|
+
localidades_iter = (
|
|
141
|
+
dict(
|
|
142
|
+
nc=str(localidade.nivel.id),
|
|
143
|
+
nn=localidade.nivel.nome,
|
|
144
|
+
d1c=str(localidade.id),
|
|
145
|
+
d1n=localidade.nome,
|
|
146
|
+
)
|
|
147
|
+
for localidade in agregado.localidades
|
|
148
|
+
)
|
|
149
|
+
with engine.connect() as conn:
|
|
150
|
+
while True:
|
|
151
|
+
batch = list(itertools.islice(localidades_iter, _BATCH_SIZE))
|
|
152
|
+
if not batch:
|
|
153
|
+
break
|
|
154
|
+
stmt = pg_insert(models.Localidade.__table__).values(batch)
|
|
155
|
+
conn.execute(stmt.on_conflict_do_nothing())
|
|
156
|
+
conn.commit()
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
# ---------------------------------------------------------------------------
|
|
160
|
+
# Lookups
|
|
161
|
+
# ---------------------------------------------------------------------------
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _localidade_lookup_query(
|
|
165
|
+
conn: sa.Connection, keys: Iterable[tuple] | None = None
|
|
166
|
+
) -> dict[tuple, int]:
|
|
167
|
+
"""Return a mapping of (nc, d1c) -> localidade.id using an open connection."""
|
|
168
|
+
lookup: dict[tuple, int] = {}
|
|
169
|
+
stmt = sa.select(
|
|
170
|
+
models.Localidade.id,
|
|
171
|
+
models.Localidade.nc,
|
|
172
|
+
models.Localidade.d1c,
|
|
173
|
+
)
|
|
174
|
+
if keys is not None:
|
|
175
|
+
keys = list(keys)
|
|
176
|
+
if not keys:
|
|
177
|
+
return lookup
|
|
178
|
+
for i in range(0, len(keys), _BATCH_SIZE):
|
|
179
|
+
chunk_stmt = stmt.where(
|
|
180
|
+
sa.tuple_(models.Localidade.nc, models.Localidade.d1c).in_(
|
|
181
|
+
keys[i : i + _BATCH_SIZE]
|
|
182
|
+
)
|
|
183
|
+
)
|
|
184
|
+
for row in conn.execute(chunk_stmt):
|
|
185
|
+
lookup[(row.nc, row.d1c)] = row.id
|
|
186
|
+
else:
|
|
187
|
+
for row in conn.execute(stmt):
|
|
188
|
+
lookup[(row.nc, row.d1c)] = row.id
|
|
189
|
+
return lookup
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def build_localidade_lookup(
|
|
193
|
+
engine: sa.Engine, keys: Iterable[tuple] | None = None
|
|
194
|
+
) -> dict[tuple, int]:
|
|
195
|
+
"""Return a mapping of (nc, d1c) -> localidade.id."""
|
|
196
|
+
with engine.connect() as conn:
|
|
197
|
+
return _localidade_lookup_query(conn, keys)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _dimensao_lookup_query(
|
|
201
|
+
conn: sa.Connection, keys: Iterable[tuple] | None = None
|
|
202
|
+
) -> dict[tuple, int]:
|
|
203
|
+
"""Return a mapping of (mc, d2c, d4c...d9c) -> dimensao.id using an open
|
|
204
|
+
connection.
|
|
205
|
+
"""
|
|
206
|
+
lookup: dict[tuple, int] = {}
|
|
207
|
+
stmt = sa.select(
|
|
208
|
+
models.Dimensao.id,
|
|
209
|
+
models.Dimensao.mc,
|
|
210
|
+
models.Dimensao.d2c,
|
|
211
|
+
models.Dimensao.d4c,
|
|
212
|
+
models.Dimensao.d5c,
|
|
213
|
+
models.Dimensao.d6c,
|
|
214
|
+
models.Dimensao.d7c,
|
|
215
|
+
models.Dimensao.d8c,
|
|
216
|
+
models.Dimensao.d9c,
|
|
217
|
+
)
|
|
218
|
+
if keys is not None:
|
|
219
|
+
# key format: (mc, d2c, d4c, d5c, d6c, d7c, d8c, d9c) — d2c is at index 1
|
|
220
|
+
d2c_keys = list({k[1] for k in keys if k is not None and k[1] is not None})
|
|
221
|
+
if not d2c_keys:
|
|
222
|
+
return lookup
|
|
223
|
+
for i in range(0, len(d2c_keys), _BATCH_SIZE):
|
|
224
|
+
chunk_stmt = stmt.where(
|
|
225
|
+
models.Dimensao.d2c.in_(d2c_keys[i : i + _BATCH_SIZE])
|
|
226
|
+
)
|
|
227
|
+
for row in conn.execute(chunk_stmt):
|
|
228
|
+
lookup[
|
|
229
|
+
(
|
|
230
|
+
row.mc,
|
|
231
|
+
row.d2c,
|
|
232
|
+
row.d4c,
|
|
233
|
+
row.d5c,
|
|
234
|
+
row.d6c,
|
|
235
|
+
row.d7c,
|
|
236
|
+
row.d8c,
|
|
237
|
+
row.d9c,
|
|
238
|
+
)
|
|
239
|
+
] = row.id
|
|
240
|
+
else:
|
|
241
|
+
for row in conn.execute(stmt):
|
|
242
|
+
lookup[
|
|
243
|
+
(
|
|
244
|
+
row.mc,
|
|
245
|
+
row.d2c,
|
|
246
|
+
row.d4c,
|
|
247
|
+
row.d5c,
|
|
248
|
+
row.d6c,
|
|
249
|
+
row.d7c,
|
|
250
|
+
row.d8c,
|
|
251
|
+
row.d9c,
|
|
252
|
+
)
|
|
253
|
+
] = row.id
|
|
254
|
+
return lookup
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def build_dimensao_lookup(
|
|
258
|
+
engine: sa.Engine, keys: Iterable[tuple] | None = None
|
|
259
|
+
) -> dict[tuple, int]:
|
|
260
|
+
"""Return a mapping of (mc, d2c, d4c...d9c) -> dimensao.id."""
|
|
261
|
+
with engine.connect() as conn:
|
|
262
|
+
return _dimensao_lookup_query(conn, keys)
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _periodo_lookup_query(
|
|
266
|
+
conn: sa.Connection, keys: Iterable[tuple] | None = None
|
|
267
|
+
) -> dict[tuple, int]:
|
|
268
|
+
"""Return a mapping of (codigo, literals) -> periodo.id using an open connection."""
|
|
269
|
+
lookup: dict[tuple, int] = {}
|
|
270
|
+
stmt = sa.select(
|
|
271
|
+
models.Periodo.id,
|
|
272
|
+
models.Periodo.codigo,
|
|
273
|
+
models.Periodo.literals,
|
|
274
|
+
)
|
|
275
|
+
if keys is not None:
|
|
276
|
+
keys = list(keys)
|
|
277
|
+
if not keys:
|
|
278
|
+
return lookup
|
|
279
|
+
# Extract unique codigos from keys for batch querying
|
|
280
|
+
codigos = list({k[0] for k in keys if k and k[0] is not None})
|
|
281
|
+
if not codigos:
|
|
282
|
+
return lookup
|
|
283
|
+
for i in range(0, len(codigos), _BATCH_SIZE):
|
|
284
|
+
chunk_stmt = stmt.where(
|
|
285
|
+
models.Periodo.codigo.in_(codigos[i : i + _BATCH_SIZE])
|
|
286
|
+
)
|
|
287
|
+
for row in conn.execute(chunk_stmt):
|
|
288
|
+
literals_tuple = tuple(row.literals) if row.literals else ()
|
|
289
|
+
lookup[(row.codigo, literals_tuple)] = row.id
|
|
290
|
+
else:
|
|
291
|
+
for row in conn.execute(stmt):
|
|
292
|
+
literals_tuple = tuple(row.literals) if row.literals else ()
|
|
293
|
+
lookup[(row.codigo, literals_tuple)] = row.id
|
|
294
|
+
return lookup
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def build_periodo_lookup(
|
|
298
|
+
engine: sa.Engine, keys: Iterable[tuple] | None = None
|
|
299
|
+
) -> dict[tuple, int]:
|
|
300
|
+
"""Return a mapping of (codigo, literals) -> periodo.id."""
|
|
301
|
+
with engine.connect() as conn:
|
|
302
|
+
return _periodo_lookup_query(conn, keys)
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
# ---------------------------------------------------------------------------
|
|
306
|
+
# ETL
|
|
307
|
+
# ---------------------------------------------------------------------------
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
_STAGING_DDL = (
|
|
311
|
+
"CREATE TEMP TABLE _staging_dados ("
|
|
312
|
+
" tabela_sidra_id text,"
|
|
313
|
+
" localidade_id bigint,"
|
|
314
|
+
" dimensao_id bigint,"
|
|
315
|
+
" periodo_id integer,"
|
|
316
|
+
" modificacao date,"
|
|
317
|
+
" v text"
|
|
318
|
+
") ON COMMIT DROP"
|
|
319
|
+
)
|
|
320
|
+
|
|
321
|
+
_STAGING_COPY = (
|
|
322
|
+
"COPY _staging_dados"
|
|
323
|
+
" (tabela_sidra_id, localidade_id, dimensao_id,"
|
|
324
|
+
" periodo_id, modificacao, v)"
|
|
325
|
+
" FROM STDIN"
|
|
326
|
+
)
|
|
327
|
+
|
|
328
|
+
# Vintages distintos presentes no staging, em ordem ascendente. Processar nessa
|
|
329
|
+
# ordem garante que o histórico de change-points seja construído corretamente e
|
|
330
|
+
# que a revisão mais recente termine ativa.
|
|
331
|
+
_STAGING_MODIFICACOES = (
|
|
332
|
+
"SELECT DISTINCT modificacao FROM _staging_dados ORDER BY modificacao"
|
|
333
|
+
)
|
|
334
|
+
|
|
335
|
+
# Desativa a linha ativa de uma chave quando o valor do vintage corrente difere
|
|
336
|
+
# (detecção-de-mudança). Recarga idêntica não toca nada.
|
|
337
|
+
_DEACTIVATE_GROUP = (
|
|
338
|
+
"UPDATE dados d"
|
|
339
|
+
" SET ativo = FALSE"
|
|
340
|
+
" FROM _staging_dados s"
|
|
341
|
+
" WHERE s.modificacao = %(mod)s"
|
|
342
|
+
" AND d.tabela_sidra_id = s.tabela_sidra_id"
|
|
343
|
+
" AND d.localidade_id = s.localidade_id"
|
|
344
|
+
" AND d.dimensao_id = s.dimensao_id"
|
|
345
|
+
" AND d.periodo_id = s.periodo_id"
|
|
346
|
+
" AND d.ativo = TRUE"
|
|
347
|
+
" AND d.v IS DISTINCT FROM s.v"
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
# Insere o vintage como nova linha ativa quando não há ativa para a chave
|
|
351
|
+
# (novo ou recém-desativado acima) ou quando o valor difere. DISTINCT ON evita
|
|
352
|
+
# inserir >1 linha por chave no mesmo grupo de modificacao.
|
|
353
|
+
_INSERT_GROUP = (
|
|
354
|
+
"INSERT INTO dados"
|
|
355
|
+
" (tabela_sidra_id, localidade_id, dimensao_id, periodo_id, modificacao, ativo, v)"
|
|
356
|
+
" SELECT DISTINCT ON"
|
|
357
|
+
" (s.tabela_sidra_id, s.localidade_id, s.dimensao_id, s.periodo_id)"
|
|
358
|
+
" s.tabela_sidra_id, s.localidade_id, s.dimensao_id, s.periodo_id,"
|
|
359
|
+
" s.modificacao, TRUE, s.v"
|
|
360
|
+
" FROM _staging_dados s"
|
|
361
|
+
" LEFT JOIN dados d"
|
|
362
|
+
" ON d.tabela_sidra_id = s.tabela_sidra_id"
|
|
363
|
+
" AND d.localidade_id = s.localidade_id"
|
|
364
|
+
" AND d.dimensao_id = s.dimensao_id"
|
|
365
|
+
" AND d.periodo_id = s.periodo_id"
|
|
366
|
+
" AND d.ativo = TRUE"
|
|
367
|
+
" WHERE s.modificacao = %(mod)s"
|
|
368
|
+
" AND (d.id IS NULL OR d.v IS DISTINCT FROM s.v)"
|
|
369
|
+
" ORDER BY s.tabela_sidra_id, s.localidade_id, s.dimensao_id, s.periodo_id"
|
|
370
|
+
)
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def _loc_key(r: dict) -> tuple[str, str]:
|
|
374
|
+
"""Return the (nc, d1c) lookup key for a data row."""
|
|
375
|
+
return (_normalize_nc(_clean_str(r.get("NC"))), _clean_str(r.get("D1C")))
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def _dim_key(r: dict) -> tuple:
|
|
379
|
+
"""Return the (mc, d2c, d4c..d9c) lookup key for a data row."""
|
|
380
|
+
return (
|
|
381
|
+
_coerce(r.get("MC")),
|
|
382
|
+
_coerce(r.get("D2C")),
|
|
383
|
+
_coerce(r.get("D4C")),
|
|
384
|
+
_coerce(r.get("D5C")),
|
|
385
|
+
_coerce(r.get("D6C")),
|
|
386
|
+
_coerce(r.get("D7C")),
|
|
387
|
+
_coerce(r.get("D8C")),
|
|
388
|
+
_coerce(r.get("D9C")),
|
|
389
|
+
)
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def _collect_upsert_data(
|
|
393
|
+
storage: Storage,
|
|
394
|
+
table_files: list[dict],
|
|
395
|
+
on_file_done: Callable[[], None] | None = None,
|
|
396
|
+
) -> tuple[list[dict], list[dict], set[tuple], Iterable[tuple], set[str], bool]:
|
|
397
|
+
"""Scan data files (Pass 1) and collect unique localidades, dimensions,
|
|
398
|
+
and periodo codigos.
|
|
399
|
+
|
|
400
|
+
Returns (loc_dicts, dim_dicts, seen_locs, dim_keys, seen_periodos, has_data).
|
|
401
|
+
"""
|
|
402
|
+
seen_locs: set[tuple] = set()
|
|
403
|
+
loc_dicts: list[dict] = []
|
|
404
|
+
seen_dim_full: dict[tuple, dict] = {}
|
|
405
|
+
seen_periodos: set[str] = set()
|
|
406
|
+
has_data = False
|
|
407
|
+
|
|
408
|
+
for data_file in table_files:
|
|
409
|
+
for row in storage.read_data(data_file["filepath"]):
|
|
410
|
+
if row.get("V") is None:
|
|
411
|
+
continue
|
|
412
|
+
has_data = True
|
|
413
|
+
|
|
414
|
+
lk = _loc_key(row)
|
|
415
|
+
if lk not in seen_locs:
|
|
416
|
+
seen_locs.add(lk)
|
|
417
|
+
loc_dicts.append(
|
|
418
|
+
{
|
|
419
|
+
"nc": lk[0],
|
|
420
|
+
"nn": str(row.get("NN", "")).strip(),
|
|
421
|
+
"d1c": lk[1],
|
|
422
|
+
"d1n": str(row.get("D1N", "")).strip(),
|
|
423
|
+
}
|
|
424
|
+
)
|
|
425
|
+
|
|
426
|
+
dim_full_key = _dim_key(row)
|
|
427
|
+
if dim_full_key not in seen_dim_full:
|
|
428
|
+
seen_dim_full[dim_full_key] = {
|
|
429
|
+
"mc": _coerce(row.get("MC")),
|
|
430
|
+
"mn": _coerce(row.get("MN")) or "",
|
|
431
|
+
"d2c": _coerce(row.get("D2C")) or "",
|
|
432
|
+
"d2n": _coerce(row.get("D2N")) or "",
|
|
433
|
+
"d4c": _coerce(row.get("D4C")),
|
|
434
|
+
"d4n": _coerce(row.get("D4N")),
|
|
435
|
+
"d5c": _coerce(row.get("D5C")),
|
|
436
|
+
"d5n": _coerce(row.get("D5N")),
|
|
437
|
+
"d6c": _coerce(row.get("D6C")),
|
|
438
|
+
"d6n": _coerce(row.get("D6N")),
|
|
439
|
+
"d7c": _coerce(row.get("D7C")),
|
|
440
|
+
"d7n": _coerce(row.get("D7N")),
|
|
441
|
+
"d8c": _coerce(row.get("D8C")),
|
|
442
|
+
"d8n": _coerce(row.get("D8N")),
|
|
443
|
+
"d9c": _coerce(row.get("D9C")),
|
|
444
|
+
"d9n": _coerce(row.get("D9N")),
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
d3c = _coerce(row.get("D3C"))
|
|
448
|
+
if d3c:
|
|
449
|
+
seen_periodos.add(d3c)
|
|
450
|
+
|
|
451
|
+
if on_file_done is not None:
|
|
452
|
+
on_file_done()
|
|
453
|
+
|
|
454
|
+
return (
|
|
455
|
+
loc_dicts,
|
|
456
|
+
list(seen_dim_full.values()),
|
|
457
|
+
seen_locs,
|
|
458
|
+
seen_dim_full.keys(),
|
|
459
|
+
seen_periodos,
|
|
460
|
+
has_data,
|
|
461
|
+
)
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def _upsert_localidades_and_dims(
|
|
465
|
+
conn: sa.Connection, loc_dicts: list[dict], dim_dicts: list[dict]
|
|
466
|
+
):
|
|
467
|
+
"""Upsert localidades and dimensoes in batches."""
|
|
468
|
+
for i in range(0, len(loc_dicts), _BATCH_SIZE):
|
|
469
|
+
stmt = pg_insert(models.Localidade.__table__).values(
|
|
470
|
+
loc_dicts[i : i + _BATCH_SIZE]
|
|
471
|
+
)
|
|
472
|
+
conn.execute(stmt.on_conflict_do_nothing())
|
|
473
|
+
for i in range(0, len(dim_dicts), _BATCH_SIZE):
|
|
474
|
+
stmt = pg_insert(models.Dimensao.__table__).values(
|
|
475
|
+
dim_dicts[i : i + _BATCH_SIZE]
|
|
476
|
+
)
|
|
477
|
+
conn.execute(stmt.on_conflict_do_nothing())
|
|
478
|
+
conn.commit()
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
def _periodo_by_codigo_query(
|
|
482
|
+
conn: sa.Connection,
|
|
483
|
+
codigos: set[str],
|
|
484
|
+
frequencias: set[str] | None = None,
|
|
485
|
+
) -> dict[str, int]:
|
|
486
|
+
"""Return a mapping of codigo -> periodo.id using a single batched query.
|
|
487
|
+
|
|
488
|
+
When a codigo maps to multiple periodos (different literals arrays), the
|
|
489
|
+
first result is used and a warning is logged.
|
|
490
|
+
"""
|
|
491
|
+
lookup: dict[str, int] = {}
|
|
492
|
+
codigos_list = list(codigos)
|
|
493
|
+
for i in range(0, len(codigos_list), _BATCH_SIZE):
|
|
494
|
+
stmt = sa.select(models.Periodo.id, models.Periodo.codigo).where(
|
|
495
|
+
models.Periodo.codigo.in_(codigos_list[i : i + _BATCH_SIZE])
|
|
496
|
+
)
|
|
497
|
+
if frequencias:
|
|
498
|
+
stmt = stmt.where(models.Periodo.frequencia.in_(frequencias))
|
|
499
|
+
for row in conn.execute(stmt):
|
|
500
|
+
if row.codigo not in lookup:
|
|
501
|
+
lookup[row.codigo] = row.id
|
|
502
|
+
else:
|
|
503
|
+
logger.warning(
|
|
504
|
+
"Multiple periodos found for codigo '%s', using id %d",
|
|
505
|
+
row.codigo,
|
|
506
|
+
lookup[row.codigo],
|
|
507
|
+
)
|
|
508
|
+
return lookup
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
def _stream_staging(
|
|
512
|
+
raw_conn,
|
|
513
|
+
storage: Storage,
|
|
514
|
+
table_files: list[dict],
|
|
515
|
+
tabela_sidra_id: str,
|
|
516
|
+
loc_lookup: dict[tuple, int],
|
|
517
|
+
dim_lookup: dict[tuple, int],
|
|
518
|
+
periodo_by_codigo: dict[str, int],
|
|
519
|
+
on_file_done: Callable[[], None] | None = None,
|
|
520
|
+
) -> tuple[int, int, int, int, int, int]:
|
|
521
|
+
"""Stream resolved rows into the staging table via COPY, then flush to dados.
|
|
522
|
+
|
|
523
|
+
Returns (n_rows, n_inserted, n_deactivated, missing_locs, missing_dims,
|
|
524
|
+
missing_periodos).
|
|
525
|
+
"""
|
|
526
|
+
missing_locs = missing_dims = missing_periodos = n_rows = 0
|
|
527
|
+
|
|
528
|
+
with raw_conn.cursor() as cur:
|
|
529
|
+
cur.execute(_STAGING_DDL)
|
|
530
|
+
with cur.copy(_STAGING_COPY) as copy:
|
|
531
|
+
for data_file in table_files:
|
|
532
|
+
modificacao = data_file["modificacao"]
|
|
533
|
+
for row in storage.read_data(data_file["filepath"]):
|
|
534
|
+
if row.get("V") is None:
|
|
535
|
+
continue
|
|
536
|
+
|
|
537
|
+
loc_id = loc_lookup.get(_loc_key(row))
|
|
538
|
+
if loc_id is None:
|
|
539
|
+
missing_locs += 1
|
|
540
|
+
continue
|
|
541
|
+
|
|
542
|
+
dim_id = dim_lookup.get(_dim_key(row))
|
|
543
|
+
if dim_id is None:
|
|
544
|
+
missing_dims += 1
|
|
545
|
+
continue
|
|
546
|
+
|
|
547
|
+
periodo_id = periodo_by_codigo.get(_coerce(row.get("D3C")))
|
|
548
|
+
if periodo_id is None:
|
|
549
|
+
missing_periodos += 1
|
|
550
|
+
continue
|
|
551
|
+
|
|
552
|
+
copy.write_row(
|
|
553
|
+
(
|
|
554
|
+
tabela_sidra_id,
|
|
555
|
+
loc_id,
|
|
556
|
+
dim_id,
|
|
557
|
+
periodo_id,
|
|
558
|
+
modificacao,
|
|
559
|
+
str(row.get("V")),
|
|
560
|
+
)
|
|
561
|
+
)
|
|
562
|
+
n_rows += 1
|
|
563
|
+
|
|
564
|
+
if on_file_done is not None:
|
|
565
|
+
on_file_done()
|
|
566
|
+
|
|
567
|
+
cur.execute(_STAGING_MODIFICACOES)
|
|
568
|
+
modificacoes = [r[0] for r in cur.fetchall()]
|
|
569
|
+
n_inserted = n_deactivated = 0
|
|
570
|
+
for mod in modificacoes:
|
|
571
|
+
cur.execute(_DEACTIVATE_GROUP, {"mod": mod})
|
|
572
|
+
n_deactivated += cur.rowcount
|
|
573
|
+
cur.execute(_INSERT_GROUP, {"mod": mod})
|
|
574
|
+
n_inserted += cur.rowcount
|
|
575
|
+
|
|
576
|
+
return (
|
|
577
|
+
n_rows,
|
|
578
|
+
n_inserted,
|
|
579
|
+
n_deactivated,
|
|
580
|
+
missing_locs,
|
|
581
|
+
missing_dims,
|
|
582
|
+
missing_periodos,
|
|
583
|
+
)
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
def ensure_vintage_schema(engine: sa.Engine) -> None:
|
|
587
|
+
"""Migrate an existing `dados` table to the vintage-storage layout.
|
|
588
|
+
|
|
589
|
+
Transitional shim — repairs pre-vintage databases that still carry the full
|
|
590
|
+
`uq_dados` unique constraint by dropping it and ensuring the partial unique
|
|
591
|
+
index (one active row per key) plus the as-of support index. Fresh databases
|
|
592
|
+
already get the correct layout from `Base.metadata.create_all`.
|
|
593
|
+
|
|
594
|
+
Self-skipping: it inspects the catalog first and only issues DDL for what is
|
|
595
|
+
actually missing. Once a database is migrated it runs **no** DDL at all (just
|
|
596
|
+
a cheap catalog read), so it never re-acquires a table lock on subsequent
|
|
597
|
+
runs. Safe to drop entirely once every deployment is known to be migrated.
|
|
598
|
+
"""
|
|
599
|
+
with engine.begin() as conn:
|
|
600
|
+
has_old_constraint = (
|
|
601
|
+
conn.execute(
|
|
602
|
+
sa.text(
|
|
603
|
+
"SELECT 1 FROM pg_constraint"
|
|
604
|
+
" WHERE conname = 'uq_dados'"
|
|
605
|
+
" AND conrelid = to_regclass('dados')"
|
|
606
|
+
)
|
|
607
|
+
).first()
|
|
608
|
+
is not None
|
|
609
|
+
)
|
|
610
|
+
has_partial_index = (
|
|
611
|
+
conn.execute(sa.text("SELECT to_regclass('uq_dados_ativo')")).scalar()
|
|
612
|
+
is not None
|
|
613
|
+
)
|
|
614
|
+
has_asof_index = (
|
|
615
|
+
conn.execute(sa.text("SELECT to_regclass('ix_dados_asof')")).scalar()
|
|
616
|
+
is not None
|
|
617
|
+
)
|
|
618
|
+
|
|
619
|
+
if not has_old_constraint and has_partial_index and has_asof_index:
|
|
620
|
+
return # already migrated — no DDL, no lock
|
|
621
|
+
|
|
622
|
+
if has_old_constraint:
|
|
623
|
+
conn.exec_driver_sql("ALTER TABLE dados DROP CONSTRAINT uq_dados")
|
|
624
|
+
if not has_partial_index:
|
|
625
|
+
conn.exec_driver_sql(
|
|
626
|
+
"CREATE UNIQUE INDEX uq_dados_ativo"
|
|
627
|
+
" ON dados (tabela_sidra_id, localidade_id, dimensao_id, periodo_id)"
|
|
628
|
+
" WHERE ativo"
|
|
629
|
+
)
|
|
630
|
+
if not has_asof_index:
|
|
631
|
+
conn.exec_driver_sql(
|
|
632
|
+
"CREATE INDEX ix_dados_asof"
|
|
633
|
+
" ON dados (tabela_sidra_id, periodo_id, modificacao)"
|
|
634
|
+
)
|
|
635
|
+
|
|
636
|
+
|
|
637
|
+
def get_loaded_filenames(engine: sa.Engine, filenames: set[str]) -> set[str]:
|
|
638
|
+
"""Return the subset of filenames already recorded in arquivo_carregado."""
|
|
639
|
+
if not filenames:
|
|
640
|
+
return set()
|
|
641
|
+
with engine.connect() as conn:
|
|
642
|
+
result = conn.execute(
|
|
643
|
+
sa.select(models.ArquivoCarregado.arquivo).where(
|
|
644
|
+
models.ArquivoCarregado.arquivo.in_(filenames)
|
|
645
|
+
)
|
|
646
|
+
)
|
|
647
|
+
return {row.arquivo for row in result}
|
|
648
|
+
|
|
649
|
+
|
|
650
|
+
def load_dados(
|
|
651
|
+
engine: sa.Engine,
|
|
652
|
+
storage: Storage,
|
|
653
|
+
data_files: list[dict[str, Any]],
|
|
654
|
+
on_file_done: Callable[[str], None] | None = None,
|
|
655
|
+
on_table_done: Callable[[str], None] | None = None,
|
|
656
|
+
):
|
|
657
|
+
"""Load data rows from JSON files into the dados table.
|
|
658
|
+
|
|
659
|
+
Also upserts localidades and dimensions found in the data files,
|
|
660
|
+
so a separate upsert call is not needed. Files are grouped by
|
|
661
|
+
SIDRA table and loaded with a two-pass approach:
|
|
662
|
+
|
|
663
|
+
* Pass 1 — collect unique localidade/dimension rows and lookup keys
|
|
664
|
+
(small memory footprint).
|
|
665
|
+
* Between passes — upsert localidades and dimensions, then build
|
|
666
|
+
ID lookup dicts.
|
|
667
|
+
* Pass 2 — re-read the JSON files and stream resolved rows into a
|
|
668
|
+
temporary staging table via the PostgreSQL COPY protocol, then merge
|
|
669
|
+
into dados per vintage (modificacao) in ascending order using
|
|
670
|
+
deactivate-then-insert by value difference (vintage storage): each
|
|
671
|
+
distinct value becomes a retained row, the latest stays `ativo`.
|
|
672
|
+
"""
|
|
673
|
+
files_by_table: dict[str, list[dict]] = {}
|
|
674
|
+
for data_file in data_files:
|
|
675
|
+
tabela_sidra_id = str(data_file["tabela_sidra"])
|
|
676
|
+
|
|
677
|
+
files_by_table.setdefault(tabela_sidra_id, []).append(data_file)
|
|
678
|
+
|
|
679
|
+
for tabela_sidra_id, table_files in files_by_table.items():
|
|
680
|
+
_file_done: Callable[[], None] | None = None
|
|
681
|
+
if on_file_done is not None:
|
|
682
|
+
sid = tabela_sidra_id
|
|
683
|
+
|
|
684
|
+
def _file_done(s=sid) -> None:
|
|
685
|
+
on_file_done(s)
|
|
686
|
+
|
|
687
|
+
(
|
|
688
|
+
loc_dicts,
|
|
689
|
+
dim_dicts,
|
|
690
|
+
seen_locs,
|
|
691
|
+
seen_dim_lookup,
|
|
692
|
+
seen_periodos,
|
|
693
|
+
has_data,
|
|
694
|
+
) = _collect_upsert_data(storage, table_files, on_file_done=_file_done)
|
|
695
|
+
|
|
696
|
+
if not has_data:
|
|
697
|
+
logger.info("No data rows found for table %s", tabela_sidra_id)
|
|
698
|
+
|
|
699
|
+
if on_table_done is not None:
|
|
700
|
+
on_table_done(tabela_sidra_id)
|
|
701
|
+
continue
|
|
702
|
+
|
|
703
|
+
logger.info(
|
|
704
|
+
"Collected %d unique periodo codigos from data for table %s",
|
|
705
|
+
len(seen_periodos),
|
|
706
|
+
tabela_sidra_id,
|
|
707
|
+
)
|
|
708
|
+
|
|
709
|
+
with engine.connect() as conn:
|
|
710
|
+
_upsert_localidades_and_dims(conn, loc_dicts, dim_dicts)
|
|
711
|
+
logger.info(
|
|
712
|
+
"Upserted %d localidades and %d dimensions for table %s",
|
|
713
|
+
len(loc_dicts),
|
|
714
|
+
len(dim_dicts),
|
|
715
|
+
tabela_sidra_id,
|
|
716
|
+
)
|
|
717
|
+
|
|
718
|
+
loc_lookup = _localidade_lookup_query(conn, keys=seen_locs)
|
|
719
|
+
dim_lookup = _dimensao_lookup_query(conn, keys=seen_dim_lookup)
|
|
720
|
+
periodicidade = conn.execute(
|
|
721
|
+
sa.select(models.TabelaSidra.periodicidade).where(
|
|
722
|
+
models.TabelaSidra.id == tabela_sidra_id
|
|
723
|
+
)
|
|
724
|
+
).scalar_one_or_none()
|
|
725
|
+
periodo_by_codigo = _periodo_by_codigo_query(
|
|
726
|
+
conn,
|
|
727
|
+
seen_periodos,
|
|
728
|
+
frequencias=expected_periodo_frequencias(periodicidade),
|
|
729
|
+
)
|
|
730
|
+
logger.info(
|
|
731
|
+
"Matched %d periodos out of %d unique codigos from data",
|
|
732
|
+
len(periodo_by_codigo),
|
|
733
|
+
len(seen_periodos),
|
|
734
|
+
)
|
|
735
|
+
|
|
736
|
+
raw_conn = conn.connection.dbapi_connection
|
|
737
|
+
(
|
|
738
|
+
n_rows,
|
|
739
|
+
n_inserted,
|
|
740
|
+
n_deactivated,
|
|
741
|
+
missing_locs,
|
|
742
|
+
missing_dims,
|
|
743
|
+
missing_periodos,
|
|
744
|
+
) = _stream_staging(
|
|
745
|
+
raw_conn,
|
|
746
|
+
storage,
|
|
747
|
+
table_files,
|
|
748
|
+
tabela_sidra_id,
|
|
749
|
+
loc_lookup,
|
|
750
|
+
dim_lookup,
|
|
751
|
+
periodo_by_codigo,
|
|
752
|
+
on_file_done=_file_done,
|
|
753
|
+
)
|
|
754
|
+
stmt = pg_insert(models.ArquivoCarregado.__table__).values(
|
|
755
|
+
[
|
|
756
|
+
{
|
|
757
|
+
"arquivo": f["filepath"].name,
|
|
758
|
+
"tabela_sidra_id": tabela_sidra_id,
|
|
759
|
+
}
|
|
760
|
+
for f in table_files
|
|
761
|
+
]
|
|
762
|
+
)
|
|
763
|
+
conn.execute(stmt.on_conflict_do_nothing())
|
|
764
|
+
conn.commit()
|
|
765
|
+
|
|
766
|
+
if on_table_done is not None:
|
|
767
|
+
on_table_done(tabela_sidra_id)
|
|
768
|
+
|
|
769
|
+
if missing_dims > 0:
|
|
770
|
+
logger.warning(
|
|
771
|
+
"Skipping %d rows with unknown dimensao for table %s",
|
|
772
|
+
missing_dims,
|
|
773
|
+
tabela_sidra_id,
|
|
774
|
+
)
|
|
775
|
+
if missing_locs > 0:
|
|
776
|
+
logger.warning(
|
|
777
|
+
"Skipping %d rows with unknown localidade for table %s",
|
|
778
|
+
missing_locs,
|
|
779
|
+
tabela_sidra_id,
|
|
780
|
+
)
|
|
781
|
+
if missing_periodos > 0:
|
|
782
|
+
logger.warning(
|
|
783
|
+
"Skipping %d rows with unknown periodo for table %s",
|
|
784
|
+
missing_periodos,
|
|
785
|
+
tabela_sidra_id,
|
|
786
|
+
)
|
|
787
|
+
logger.info(
|
|
788
|
+
"Loaded %d/%d rows into dados for table %s (%d deactivated)",
|
|
789
|
+
n_inserted,
|
|
790
|
+
n_rows,
|
|
791
|
+
tabela_sidra_id,
|
|
792
|
+
n_deactivated,
|
|
793
|
+
)
|