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/storage.py ADDED
@@ -0,0 +1,156 @@
1
+ """Storage repository for SIDRA data files.
2
+
3
+ This module provides the `Storage` class, which centralizes all
4
+ filesystem operations for SIDRA tables: constructing deterministic file
5
+ paths from a `Parametro`, checking whether a file already exists, and
6
+ reading/writing JSON data files.
7
+ """
8
+
9
+ import logging
10
+ from pathlib import Path
11
+
12
+ import orjson
13
+ from sidra_fetcher.agregados import Agregado
14
+ from sidra_fetcher.reader import load_agregado, save_agregado
15
+ from sidra_fetcher.sidra import Parametro
16
+
17
+ from .config import Config
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+
22
+ class Storage:
23
+ def __init__(self, data_dir: Path | str):
24
+ self.data_dir = Path(data_dir)
25
+
26
+ @classmethod
27
+ def default(cls, config: Config) -> "Storage":
28
+ """Create a Storage rooted at the data directory from config."""
29
+ data_dir = config.data_dir
30
+ data_dir.mkdir(exist_ok=True, parents=True)
31
+ return cls(data_dir)
32
+
33
+ @staticmethod
34
+ def build_data_filename(parameter: Parametro, modification: str) -> str:
35
+ """Build a deterministic filename for a SIDRA `Parametro`.
36
+
37
+ The filename encodes the table id, periods, format, territorial
38
+ levels, variables and classifications so that each unique request
39
+ maps to a unique file name. The returned filename ends with
40
+ ``@{modification}.json`` where ``modification`` is typically the
41
+ period modification timestamp used by the API.
42
+
43
+ Args:
44
+ parameter: A `Parametro` instance containing request
45
+ configuration (table, periods, territorios, variaveis,
46
+ classificacoes, formato).
47
+ modification: A string representing the modification
48
+ timestamp to append to the filename.
49
+
50
+ Returns:
51
+ A string suitable for use as a JSON filename.
52
+ """
53
+ sidra_table = parameter.agregado
54
+ periods = ",".join(parameter.periodos)
55
+ formato = parameter.formato.value
56
+ name = f"t-{sidra_table}_p-{periods}_f-{formato}"
57
+ for territorial_level in parameter.territorios:
58
+ territorial_codes = ",".join(
59
+ str(code) for code in parameter.territorios[territorial_level]
60
+ )
61
+ if territorial_codes == "":
62
+ territorial_codes = "all"
63
+ name += f"_n{territorial_level}-{territorial_codes}"
64
+ if parameter.variaveis:
65
+ variables = ",".join(str(var) for var in parameter.variaveis)
66
+ name += f"_v-{variables}"
67
+ for classification, categories in parameter.classificacoes.items():
68
+ str_categories = ",".join(categories)
69
+ name += f"_c{classification}-{str_categories}"
70
+ name += f"@{modification}"
71
+ name += ".json"
72
+ return name
73
+
74
+ def get_metadata_filepath(self, agregado: int | str) -> Path:
75
+ """Return the full path for a table's metadata JSON file."""
76
+ return self.data_dir / f"t-{agregado}" / "metadados.json"
77
+
78
+ def get_data_filepath(
79
+ self,
80
+ parameter: Parametro,
81
+ modification: str,
82
+ ) -> Path:
83
+ """Return the full path for the given parameter and modification."""
84
+ filename = self.build_data_filename(parameter, modification)
85
+ return self.data_dir / f"t-{parameter.agregado}" / filename
86
+
87
+ def exists(self, parameter: Parametro, modification: str) -> bool:
88
+ """Return True if the file for the given parameter already exists."""
89
+ return self.get_data_filepath(parameter, modification).exists()
90
+
91
+ def write_data(
92
+ self,
93
+ data: dict,
94
+ parameter: Parametro,
95
+ modification: str,
96
+ ) -> Path:
97
+ """Write *data* to disk as JSON and return the destination path."""
98
+ filepath = self.get_data_filepath(parameter, modification)
99
+ filepath.parent.mkdir(parents=True, exist_ok=True)
100
+ logger.info("Writing file %s", filepath)
101
+ with filepath.open("w", encoding="utf-8") as f:
102
+ f.write(orjson.dumps(data, option=orjson.OPT_INDENT_2).decode())
103
+ return filepath
104
+
105
+ def read_data(self, filepath: Path) -> list[dict]:
106
+ """Read a JSON file previously written by `write`.
107
+
108
+ Args:
109
+ filepath: Path to the JSON file to read.
110
+
111
+ Returns:
112
+ A list of dicts containing the table data.
113
+ """
114
+ logger.info("Reading file %s", filepath)
115
+ with filepath.open("rb") as f:
116
+ data = orjson.loads(f.read())
117
+
118
+ if len(data) > 1:
119
+ rows = data[1:]
120
+ for row in rows:
121
+ for k, v in row.items():
122
+ if v in ("...", "-"):
123
+ row[k] = None
124
+ return rows
125
+ return []
126
+
127
+ def write_metadata(self, agregado: Agregado) -> Path:
128
+ """Write *agregado* metadata to disk as JSON and return the destination path."""
129
+ filepath = self.get_metadata_filepath(agregado.id)
130
+ filepath.parent.mkdir(parents=True, exist_ok=True)
131
+ logger.info("Writing file %s", filepath)
132
+ save_agregado(agregado, filepath)
133
+ return filepath
134
+
135
+ def read_metadata(self, agregado: int | str) -> Agregado:
136
+ """Read metadata from disk as JSON and return the destination path."""
137
+ filepath = self.get_metadata_filepath(agregado)
138
+ agregado = load_agregado(filepath)
139
+ return agregado
140
+
141
+ def read_data_dir(self, dirpath: Path) -> list[dict]:
142
+ # Group files by base name (before the @modification suffix) and keep
143
+ # only the file with the latest modification per parameter combination.
144
+ latest: dict[str, tuple[Path, str]] = {}
145
+ for f in dirpath.glob("*.json"):
146
+ stem = f.stem
147
+ if "@" in stem:
148
+ base, mod = stem.rsplit("@", 1)
149
+ else:
150
+ base, mod = stem, ""
151
+ if base not in latest or mod > latest[base][1]:
152
+ latest[base] = (f, mod)
153
+ data = []
154
+ for f, _ in latest.values():
155
+ data.extend(self.read_data(f))
156
+ return data
@@ -0,0 +1,374 @@
1
+ """Pipeline runner driven by a TOML configuration file.
2
+
3
+ ``TomlScript`` reads a TOML file that declares which SIDRA tables to fetch
4
+ and drives the full ETL pipeline: metadata → download → database load.
5
+
6
+ TOML schema
7
+ -----------
8
+ Each ``[[tabelas]]`` entry maps directly to a `sidra.Fetcher.download_table`
9
+ call. Two optional boolean flags extend the static format:
10
+
11
+ ``unnest_classifications = true``
12
+ Fetch the table's metadata at runtime and expand every classification /
13
+ category combination with `sidra.unnest_classificacoes`. The
14
+ ``classifications`` key, if present, is ignored.
15
+
16
+ ``unnest_classifications = ["id1", "id2"]``
17
+ Same expansion, but only for the listed classification IDs (integers also
18
+ accepted). The ``classifications`` key, if present, is merged as static
19
+ defaults — unnested entries take precedence on key conflicts.
20
+
21
+ ``split_variables = true``
22
+ Issue one request per variable listed in ``variables`` instead of a
23
+ single request with all variables.
24
+
25
+ Example TOML
26
+ ~~~~~~~~~~~~
27
+ ::
28
+
29
+ [[tabelas]]
30
+ tabela_sidra = "5938"
31
+ variables = ["37", "498"]
32
+ territories = {6 = ["all"]}
33
+
34
+ [[tabelas]]
35
+ tabela_sidra = "1613"
36
+ variables = ["allxp"]
37
+ territories = {6 = []}
38
+ unnest_classifications = true
39
+
40
+ [[tabelas]]
41
+ tabela_sidra = "5938"
42
+ variables = ["allxp"]
43
+ territories = {6 = []}
44
+ classifications = {81 = ["allxt"]}
45
+ unnest_classifications = ["87"]
46
+
47
+ [[tabelas]]
48
+ tabela_sidra = "1002"
49
+ variables = ["109", "216", "214", "112"]
50
+ split_variables = true
51
+ territories = {6 = []}
52
+ classifications = {81 = ["allxt"]}
53
+ """
54
+
55
+ import logging
56
+ import tomllib
57
+ from collections.abc import Iterable
58
+ from pathlib import Path
59
+ from typing import Any
60
+
61
+ import sqlalchemy as sa
62
+ from rich.console import Console
63
+ from rich.progress import (
64
+ BarColumn,
65
+ MofNCompleteColumn,
66
+ Progress,
67
+ SpinnerColumn,
68
+ TaskID,
69
+ TextColumn,
70
+ TimeElapsedColumn,
71
+ TimeRemainingColumn,
72
+ )
73
+ from rich.table import Table
74
+ from rich.text import Text
75
+
76
+ from . import database, models, sidra
77
+ from .config import Config
78
+ from .storage import Storage
79
+
80
+ logger = logging.getLogger(__name__)
81
+
82
+
83
+ class _MainOnlyTimeElapsedColumn(TimeElapsedColumn):
84
+ def render(self, task):
85
+ if not task.fields.get("main"):
86
+ return Text("")
87
+ return super().render(task)
88
+
89
+
90
+ class _MainOnlyTimeRemainingColumn(TimeRemainingColumn):
91
+ def render(self, task):
92
+ if not task.fields.get("main"):
93
+ return Text("")
94
+ return super().render(task)
95
+
96
+
97
+ def _make_progress(console: Console | None) -> Progress:
98
+ return Progress(
99
+ SpinnerColumn(finished_text="[green]✓[/green]"),
100
+ TextColumn("[progress.description]{task.description}", table_column=None),
101
+ BarColumn(bar_width=28),
102
+ TextColumn("[progress.percentage]{task.percentage:>3.0f}%", style="grey70"),
103
+ _MainOnlyTimeElapsedColumn(),
104
+ _MainOnlyTimeRemainingColumn(),
105
+ console=console,
106
+ transient=False,
107
+ disable=console is None,
108
+ )
109
+
110
+
111
+ def _make_download_progress(console: Console | None) -> Progress:
112
+ return Progress(
113
+ SpinnerColumn(finished_text="[green]✓[/green]"),
114
+ TextColumn("[progress.description]{task.description}", table_column=None),
115
+ BarColumn(bar_width=28),
116
+ MofNCompleteColumn(),
117
+ _MainOnlyTimeElapsedColumn(),
118
+ _MainOnlyTimeRemainingColumn(),
119
+ console=console,
120
+ transient=False,
121
+ disable=console is None,
122
+ )
123
+
124
+
125
+ class TomlScript:
126
+ """ETL pipeline runner that loads table definitions from a TOML file.
127
+
128
+ Drives the full pipeline:
129
+
130
+ 1. Create ORM tables if they don't exist.
131
+ 2. Fetch and save metadata (tabela_sidra, localidade).
132
+ 3. Download all data files.
133
+ 4. Load data rows into the dados table (also upserts dimensions).
134
+ """
135
+
136
+ def __init__(
137
+ self,
138
+ config: Config,
139
+ toml_path: Path,
140
+ max_workers: int = 4,
141
+ force_metadata: bool = False,
142
+ force_load: bool = False,
143
+ console: Console | None = None,
144
+ ):
145
+ self.config = config
146
+ self.toml_path = toml_path
147
+ self.force_metadata = force_metadata
148
+ self.force_load = force_load
149
+ self.console = console
150
+ self.storage = Storage.default(config)
151
+ self.fetcher = sidra.Fetcher(
152
+ config, max_workers=max_workers, storage=self.storage
153
+ )
154
+
155
+ def get_tabelas(self) -> Iterable[dict[str, Any]]:
156
+ """Read the TOML file and return an expanded list of table request dicts."""
157
+ with open(self.toml_path, "rb") as f:
158
+ data = tomllib.load(f)
159
+
160
+ result: list[dict[str, Any]] = []
161
+ for entry in data.get("tabelas", []):
162
+ entry = dict(entry)
163
+ unnest = entry.pop("unnest_classifications", False)
164
+ split_vars = entry.pop("split_variables", False)
165
+
166
+ if unnest is not False:
167
+ metadados = self.fetcher.sidra_client.get_agregado_metadados(
168
+ entry["tabela_sidra"]
169
+ )
170
+ if unnest is True:
171
+ entry.pop("classifications", None)
172
+ to_unnest = metadados.classificacoes
173
+ static_cls: dict[str, list[str]] = {}
174
+ else:
175
+ unnest_ids = {str(u) for u in unnest}
176
+ to_unnest = [
177
+ c for c in metadados.classificacoes if str(c.id) in unnest_ids
178
+ ]
179
+ static_cls = entry.pop("classifications", {})
180
+ for c in metadados.classificacoes:
181
+ cls_id = str(c.id)
182
+ if cls_id not in unnest_ids and cls_id not in static_cls:
183
+ static_cls[cls_id] = ["all"]
184
+ for cls_combo in sidra.unnest_classificacoes(to_unnest):
185
+ result.append(
186
+ {
187
+ **entry,
188
+ "classifications": {**static_cls, **cls_combo},
189
+ }
190
+ )
191
+ elif split_vars:
192
+ variables = entry.pop("variables")
193
+ for var in variables:
194
+ result.append({**entry, "variables": [var]})
195
+ else:
196
+ result.append(entry)
197
+
198
+ return result
199
+
200
+ def download(self, tabelas: Iterable[dict[str, Any]]) -> list[dict[str, Any]]:
201
+ """Download all tables and return a list of data-file descriptors.
202
+
203
+ Builds a flat plan across every requested table and submits it
204
+ through a single ``ThreadPoolExecutor`` so downloads parallelize
205
+ across table boundaries, not just across periods of one table.
206
+ """
207
+ plan: list[tuple[dict[str, Any], Any, str]] = []
208
+ for tabela in tabelas:
209
+ for parameter, modification in self.fetcher.plan_periods(**tabela):
210
+ plan.append((tabela, parameter, modification))
211
+ results = self.fetcher.download_periods(plan)
212
+ return [
213
+ r["key"] | {"filepath": r["filepath"], "modificacao": r["modificacao"]}
214
+ for r in results
215
+ ]
216
+
217
+ def load_metadata(self, engine: sa.Engine, tabelas: Iterable[dict[str, Any]]):
218
+ """Fetch and persist metadata for all unique SIDRA tables."""
219
+ seen: set[str] = set()
220
+ for tabela in tabelas:
221
+ tabela_sidra_id = tabela["tabela_sidra"]
222
+ if tabela_sidra_id in seen:
223
+ continue
224
+ seen.add(tabela_sidra_id)
225
+
226
+ metadata_filepath = self.storage.get_metadata_filepath(tabela_sidra_id)
227
+ if metadata_filepath.exists() and not self.force_metadata:
228
+ logger.info("Reading cached metadata for table %s", tabela_sidra_id)
229
+ agregado = self.storage.read_metadata(tabela_sidra_id)
230
+ else:
231
+ logger.info("Fetching metadata for table %s", tabela_sidra_id)
232
+ agregado = self.fetcher.fetch_metadata(tabela_sidra_id)
233
+ self.storage.write_metadata(agregado)
234
+
235
+ logger.info("Saving metadata to database for table %s", tabela_sidra_id)
236
+ database.save_agregado(engine, agregado)
237
+
238
+ def run(self):
239
+ """Execute the full fetch-and-load pipeline."""
240
+ engine = database.get_engine(self.config)
241
+ with engine.begin() as conn:
242
+ conn.exec_driver_sql(
243
+ f'CREATE SCHEMA IF NOT EXISTS "{self.config.db_schema}"'
244
+ )
245
+ models.Base.metadata.create_all(engine)
246
+ database.ensure_vintage_schema(engine)
247
+ try:
248
+ self._run(engine)
249
+ except KeyboardInterrupt:
250
+ if self.console is not None:
251
+ self.console.print("\n[yellow]Interrompido.[/yellow]")
252
+ raise SystemExit(1) from None
253
+
254
+ def _run(self, engine: sa.Engine):
255
+ tabelas = list(self.get_tabelas())
256
+ n_meta = len({t["tabela_sidra"] for t in tabelas})
257
+ s_meta = "tabela" if n_meta == 1 else "tabelas"
258
+
259
+ with self.fetcher:
260
+ with _make_progress(self.console) as progress:
261
+ meta_task = progress.add_task(
262
+ f"Metadados ({n_meta} {s_meta})", total=None, main=True
263
+ )
264
+ self.load_metadata(engine, tabelas)
265
+ progress.update(
266
+ meta_task,
267
+ total=1,
268
+ completed=1,
269
+ description=f"Metadados ({n_meta} {s_meta})",
270
+ )
271
+
272
+ plan: list[tuple[dict[str, Any], Any, str]] = []
273
+ for tabela in tabelas:
274
+ for parameter, modification in self.fetcher.plan_periods(**tabela):
275
+ plan.append((tabela, parameter, modification))
276
+
277
+ n_plan = len(plan)
278
+ if self.console is not None:
279
+ info = Table.grid(padding=(0, 2))
280
+ info.add_column(style="bold")
281
+ info.add_column()
282
+ info.add_row("Pipeline", str(self.toml_path))
283
+ info.add_row("Tabelas", f"{n_meta} {s_meta}")
284
+ info.add_row("Arquivos", str(n_plan))
285
+ info.add_row("Threads", str(self.fetcher.max_workers))
286
+ info.add_row(
287
+ "Banco",
288
+ f"{self.config.db_host}:{self.config.db_port}/{self.config.db_name}"
289
+ f" schema={self.config.db_schema}",
290
+ )
291
+ info.add_row("Storage", str(self.config.data_dir))
292
+ self.console.print(info)
293
+ self.console.print()
294
+
295
+ files_per_table: dict[str, int] = {}
296
+ for tabela, _, _ in plan:
297
+ sid = tabela["tabela_sidra"]
298
+ files_per_table[sid] = files_per_table.get(sid, 0) + 1
299
+
300
+ with _make_download_progress(self.console) as progress:
301
+ global_task = progress.add_task("Download", total=n_plan, main=True)
302
+ task_by_table: dict[str, TaskID] = {}
303
+ if len(files_per_table) > 1:
304
+ for sid, count in files_per_table.items():
305
+ task_by_table[sid] = progress.add_task(
306
+ f"Tabela {sid}", total=count
307
+ )
308
+
309
+ def _on_done(key: dict[str, Any]) -> None:
310
+ sub = task_by_table.get(key["tabela_sidra"])
311
+ if sub is not None:
312
+ progress.advance(sub)
313
+ progress.advance(global_task)
314
+
315
+ results = self.fetcher.download_periods(plan, on_file_done=_on_done)
316
+ data_files = [
317
+ r["key"]
318
+ | {
319
+ "filepath": r["filepath"],
320
+ "modificacao": r["modificacao"],
321
+ }
322
+ for r in results
323
+ ]
324
+ for sid, task_id in task_by_table.items():
325
+ progress.update(task_id, description=f"Tabela {sid} ✓")
326
+ progress.update(global_task, description="Download concluído ✓")
327
+
328
+ if not self.force_load:
329
+ loaded = database.get_loaded_filenames(
330
+ engine, {f["filepath"].name for f in data_files}
331
+ )
332
+ data_files = [f for f in data_files if f["filepath"].name not in loaded]
333
+ if not data_files:
334
+ if self.console is not None:
335
+ self.console.print(
336
+ "[green]Todos os arquivos já foram carregados.[/green]"
337
+ )
338
+ return
339
+
340
+ with _make_download_progress(self.console) as progress:
341
+ db_files_per_table: dict[str, int] = {}
342
+ for d in data_files:
343
+ sid = str(d["tabela_sidra"])
344
+ db_files_per_table[sid] = db_files_per_table.get(sid, 0) + 1
345
+ n_db_files = sum(db_files_per_table.values())
346
+ db_global_task = progress.add_task(
347
+ "Carregando no banco de dados", total=n_db_files * 2, main=True
348
+ )
349
+ db_task_by_table: dict[str, TaskID] = {}
350
+ if len(db_files_per_table) > 1:
351
+ for sid, count in db_files_per_table.items():
352
+ db_task_by_table[sid] = progress.add_task(
353
+ f"Tabela {sid}", total=count * 2
354
+ )
355
+
356
+ def _on_db_file_done(sid: str) -> None:
357
+ sub = db_task_by_table.get(sid)
358
+ if sub is not None:
359
+ progress.advance(sub)
360
+ progress.advance(db_global_task)
361
+
362
+ def _on_db_table_done(sid: str) -> None:
363
+ sub = db_task_by_table.get(sid)
364
+ if sub is not None:
365
+ progress.update(sub, description=f"Tabela {sid} ✓")
366
+
367
+ database.load_dados(
368
+ engine,
369
+ self.storage,
370
+ data_files,
371
+ on_file_done=_on_db_file_done,
372
+ on_table_done=_on_db_table_done,
373
+ )
374
+ progress.update(db_global_task, description="Carregamento concluído ✓")
@@ -0,0 +1,153 @@
1
+ """Pipeline runner for SQL transformations defined by ``transform.toml``.
2
+
3
+ A pipeline directory contains one ``transform.toml`` declaring one or more
4
+ output tables/views. Each ``[[table]]`` entry references its own ``.sql``
5
+ file in the same directory.
6
+
7
+ TOML schema
8
+ -----------
9
+ ::
10
+
11
+ [[table]]
12
+ name = "ipca"
13
+ schema = "analytics"
14
+ strategy = "replace" # "replace" or "view"
15
+ sql = "ipca.sql"
16
+ description = "IPCA - série detalhada"
17
+
18
+ [[table]]
19
+ name = "ipca_resumo"
20
+ schema = "analytics"
21
+ strategy = "view"
22
+ sql = "ipca_resumo.sql"
23
+
24
+ Required fields per entry: ``name``, ``schema``, ``strategy``, ``sql``.
25
+ Optional: ``description``, ``primary_key``, ``indexes``.
26
+
27
+ Strategies
28
+ ~~~~~~~~~~
29
+ ``replace``
30
+ ``DROP TABLE IF EXISTS`` followed by ``CREATE TABLE ... AS``.
31
+ Full refresh — best for batch imports into Power BI, Excel, etc.
32
+
33
+ ``view``
34
+ ``CREATE OR REPLACE VIEW``. Zero storage cost, always up-to-date,
35
+ best for live database connections.
36
+
37
+ Each entry runs in its own transaction; if one fails, previously
38
+ materialized outputs from the same pipeline persist.
39
+
40
+ The SQL queries use unqualified table names (``dados``, ``dimensao``,
41
+ ``localidade``, ``tabela_sidra``). They resolve via the database
42
+ ``search_path`` set by ``get_engine`` from ``config.ini``.
43
+ """
44
+
45
+ import logging
46
+ import tomllib
47
+ from pathlib import Path
48
+
49
+ from rich.console import Console
50
+ from rich.progress import (
51
+ Progress,
52
+ SpinnerColumn,
53
+ TextColumn,
54
+ TimeElapsedColumn,
55
+ )
56
+
57
+ from . import database
58
+ from .config import Config
59
+
60
+ logger = logging.getLogger(__name__)
61
+
62
+
63
+ class TransformRunner:
64
+ """Run SQL transformations declared in a ``transform.toml`` file."""
65
+
66
+ def __init__(self, config: Config, toml_path: Path, console: Console | None = None):
67
+ self.config = config
68
+ self.toml_path = toml_path
69
+ self.console = console
70
+
71
+ def run(self):
72
+ with open(self.toml_path, "rb") as f:
73
+ data = tomllib.load(f)
74
+
75
+ tables = data.get("table")
76
+ if not isinstance(tables, list) or not tables:
77
+ raise ValueError(
78
+ f"{self.toml_path}: esperado um ou mais [[table]] (array). "
79
+ "O schema [table] singular foi removido — migre para [[table]] "
80
+ "com campo 'sql' explícito por entrada."
81
+ )
82
+
83
+ engine = database.get_engine(self.config)
84
+
85
+ with Progress(
86
+ SpinnerColumn(finished_text="[green]✓[/green]"),
87
+ TextColumn("[progress.description]{task.description}"),
88
+ TimeElapsedColumn(),
89
+ console=self.console,
90
+ transient=False,
91
+ disable=self.console is None,
92
+ ) as progress:
93
+ for entry in tables:
94
+ self._materialize(engine, entry, progress)
95
+
96
+ def _materialize(self, engine, entry: dict, progress: Progress) -> None:
97
+ missing = [f for f in ("name", "schema", "strategy", "sql") if f not in entry]
98
+ if missing:
99
+ raise ValueError(
100
+ f"{self.toml_path}: [[table]] sem campo(s) obrigatório(s): "
101
+ f"{', '.join(missing)}"
102
+ )
103
+
104
+ name = entry["name"]
105
+ schema = entry["schema"]
106
+ strategy = entry["strategy"]
107
+ sql_rel = entry["sql"]
108
+ primary_key = entry.get("primary_key")
109
+ indexes = entry.get("indexes", [])
110
+
111
+ sql_path = self.toml_path.parent / sql_rel
112
+ if not sql_path.exists():
113
+ raise FileNotFoundError(
114
+ f"{self.toml_path}: arquivo SQL '{sql_rel}' não encontrado em "
115
+ f"{self.toml_path.parent}"
116
+ )
117
+ query = sql_path.read_text(encoding="utf-8").strip().replace("%", "%%")
118
+
119
+ qualified = f'"{schema}"."{name}"'
120
+ strategy_label = {"replace": "tabela", "view": "view"}.get(strategy, strategy)
121
+ task = progress.add_task(
122
+ f"{qualified} [dim][{strategy_label}][/dim]", total=None
123
+ )
124
+
125
+ with engine.begin() as conn:
126
+ conn.exec_driver_sql(f'CREATE SCHEMA IF NOT EXISTS "{schema}"')
127
+
128
+ if strategy == "view":
129
+ conn.exec_driver_sql(f"CREATE OR REPLACE VIEW {qualified} AS\n{query}")
130
+ elif strategy == "replace":
131
+ conn.exec_driver_sql(f"DROP TABLE IF EXISTS {qualified}")
132
+ conn.exec_driver_sql(f"CREATE TABLE {qualified} AS\n{query}")
133
+
134
+ if primary_key:
135
+ pk_cols = ", ".join(f'"{c}"' for c in primary_key)
136
+ conn.exec_driver_sql(
137
+ f"ALTER TABLE {qualified} ADD PRIMARY KEY ({pk_cols})"
138
+ )
139
+
140
+ for idx in indexes:
141
+ idx_name = idx["name"]
142
+ idx_cols = ", ".join(f'"{c}"' for c in idx["columns"])
143
+ unique = "UNIQUE" if idx.get("unique") else ""
144
+ conn.exec_driver_sql(
145
+ f'CREATE {unique} INDEX "{idx_name}" '
146
+ f"ON {qualified} ({idx_cols})"
147
+ )
148
+ else:
149
+ raise ValueError(
150
+ f"Unknown strategy {strategy!r} for {qualified} in {self.toml_path}"
151
+ )
152
+
153
+ progress.update(task, total=1, completed=1)