update dataconvert and source

This commit is contained in:
2026-09-22 11:57:31 -03:00
parent 0b227cbc0e
commit 55c0d73ffe
4 changed files with 45 additions and 11 deletions

View File

@@ -17,7 +17,7 @@ from pathlib import Path, PurePosixPath
import pandas as pd import pandas as pd
from progress import say, seconds from progress import say, seconds
from sqlgen import human_bytes from sqlgen import human_bytes, sanitize_identifier
SUPPORTED = {".csv", ".xlsx", ".xls", ".ods"} SUPPORTED = {".csv", ".xlsx", ".xls", ".ods"}
@@ -68,13 +68,33 @@ def read_sheet(read, config):
if layout is None: if layout is None:
layout = config.fallback layout = config.fallback
if layout is None: if layout is None:
return read(), None data = read()
data.columns = usable_columns(data.columns)
return data, None
raw = read(header=None) raw = read(header=None)
data = raw.iloc[layout.data_row - 1:].copy() data = raw.iloc[layout.data_row - 1:].copy()
data.columns = [str(c).strip() for c in raw.iloc[layout.header_row - 1]] data.columns = usable_columns(str(c).strip() for c in raw.iloc[layout.header_row - 1])
return data, layout return data, layout
def usable_columns(names) -> list:
"""
Header cells as SQL can take them: an empty one (or one of symbols only, which
sanitizes to nothing) becomes column_<n>, n counted from 1 as the spreadsheet does,
and a name that repeats once sanitized gets _2, _3. Every other name is kept as written.
"""
out, seen = [], set()
for n, name in enumerate(names, start=1):
name = str(name)
base = name if sanitize_identifier(name) else f"column_{n}"
candidate, k = base, 2
while sanitize_identifier(candidate) in seen:
candidate, k = f"{base}_{k}", k + 1
seen.add(sanitize_identifier(candidate))
out.append(candidate)
return out
def load_dataframes_from_file(file_path: Path, config, all_text=False) -> dict: def load_dataframes_from_file(file_path: Path, config, all_text=False) -> dict:
"""Load a file (.csv, .xlsx, .xls, .ods) into {entity_name: DataFrame}.""" """Load a file (.csv, .xlsx, .xls, .ods) into {entity_name: DataFrame}."""
ext = file_path.suffix.lower() ext = file_path.suffix.lower()

View File

@@ -62,7 +62,11 @@ def ddl_statement(table_name: str, df: pd.DataFrame) -> str:
col_type = postgres_type(df[col]) col_type = postgres_type(df[col])
cols_def.append(f' "{col_id}" {col_type}') cols_def.append(f' "{col_id}" {col_type}')
cols_sql = ",\n".join(cols_def) cols_sql = ",\n".join(cols_def)
return f'CREATE TABLE IF NOT EXISTS "{table_name}" (\n{cols_sql}\n);\n\n' # Several sources can feed one table (same-named files in different folders), each
# block with its own CREATE; only the first creates. Each block then adds the columns
# only it has, so the table ends up with every source's columns, NULL where one lacks them.
adds = "".join(f'ALTER TABLE "{table_name}" ADD COLUMN IF NOT EXISTS{line};\n' for line in cols_def)
return f'CREATE TABLE IF NOT EXISTS "{table_name}" (\n{cols_sql}\n);\n{adds}\n'
def sql_value(v) -> str: def sql_value(v) -> str:
@@ -114,6 +118,8 @@ class Shape:
def open(self, note="") -> str: def open(self, note="") -> str:
text = self.head + note + "BEGIN;\n\n" text = self.head + note + "BEGIN;\n\n"
if self.include_ddl and self.df is not None: if self.include_ddl and self.df is not None:
# IF NOT EXISTS says "skipping" once per column per block; keep the load log to errors.
text += "SET LOCAL client_min_messages = warning;\n"
text += ddl_statement(self.table_name, self.df) text += ddl_statement(self.table_name, self.df)
return text + self.copy if self.fmt == "copy" else text return text + self.copy if self.fmt == "copy" else text

View File

@@ -23,4 +23,8 @@ tables:
is_active: "COALESCE(etl_utils.safe_cast_boolean(status), TRUE)" is_active: "COALESCE(etl_utils.safe_cast_boolean(status), TRUE)"
``` ```
A target that does not exist yet is created from the mapping: its schema, then a table whose
columns are the mapping's names, typed by its expressions (`safe_cast_date` gives a `date`). A
target that already exists, from a real DDL with keys and constraints, is used as it is.
All operational variables (database credentials, dump directories, active schemas) are read from the environment or a `.env` file at the root. See `.env-example` for available configuration keys. All operational variables (database credentials, dump directories, active schemas) are read from the environment or a `.env` file at the root. See `.env-example` for available configuration keys.

View File

@@ -25,16 +25,20 @@ def main():
print(f"Migrating {source} to {target}...") print(f"Migrating {source} to {target}...")
if table.get('truncate', False):
try:
cur.execute(f"TRUNCATE TABLE {target} RESTART IDENTITY CASCADE;")
except psycopg.errors.UndefinedTable:
print(f" Target table {target} does not exist yet; skipping truncate.")
conn.rollback()
target_cols = ", ".join(columns.keys()) target_cols = ", ".join(columns.keys())
source_exprs = ", ".join(columns.values()) source_exprs = ", ".join(columns.values())
# A missing target is made from the mapping itself: its schema, then a table
# shaped by the expressions (safe_cast_date gives a date column, and so on).
# A target that exists, from a real DDL with keys and constraints, is left alone.
if "." in target:
cur.execute(f"CREATE SCHEMA IF NOT EXISTS {target.split('.', 1)[0]};")
shaped = ", ".join(f"{expr} AS {col}" for col, expr in columns.items())
cur.execute(f"CREATE TABLE IF NOT EXISTS {target} AS SELECT {shaped} FROM {source} WITH NO DATA;")
if table.get('truncate', False):
cur.execute(f"TRUNCATE TABLE {target} RESTART IDENTITY CASCADE;")
query = f""" query = f"""
INSERT INTO {target} ({target_cols}) INSERT INTO {target} ({target_cols})
SELECT {source_exprs} SELECT {source_exprs}