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/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
+ )