diff --git a/.gitignore b/.gitignore index a8351a6..5708dbb 100644 --- a/.gitignore +++ b/.gitignore @@ -32,3 +32,7 @@ output/ # Windows Thumbs.db desktop.ini + +# tests +tests/ + diff --git a/testenv.env b/testenv.env new file mode 100644 index 0000000..29c5fde --- /dev/null +++ b/testenv.env @@ -0,0 +1,2 @@ +env=dev +edw_hk_plan_ts=20260404 diff --git a/udpak_semistruktur.py b/udpak_semistruktur.py index 54654fe..3fddebf 100644 --- a/udpak_semistruktur.py +++ b/udpak_semistruktur.py @@ -32,7 +32,7 @@ def _byg_argument_parser() -> argparse.ArgumentParser: def _kør_udtræk(config: dict, global_config: dict) -> None: """Kører den normale udtræks- og transformationspipeline.""" - + print(f"DEBUG: Antal output_filer = {len(config.get('output_filer', []))}") input_fil = global_config.get("input_fil") input_fil_liste = global_config.get("input_fil_liste") @@ -120,9 +120,14 @@ def main(): opsaet_logging( log_fil=global_config["logfil"], niveau=global_config.get("log_niveau", "info"), + log_output=global_config.get("log_output", "begge"), ) # Eksekver DDL-flowet + print(f"MAIN DEBUG: output_filer count = {len(config.get('output_filer', []))}", flush=True) + for i, cfg in enumerate(config.get("output_filer", [])): + print(f"MAIN DEBUG [{i}]: rod={cfg.get('rod')}, fil={cfg.get('fil_navn')}, type={cfg.get('type')}", flush=True) + if ddl.is_enabled(args): ddl.run_ddl_mode(args, config, global_config) else: diff --git a/udpak_semistruktur/config.py b/udpak_semistruktur/config.py index de7298b..42fc916 100644 --- a/udpak_semistruktur/config.py +++ b/udpak_semistruktur/config.py @@ -354,12 +354,15 @@ def valider_yaml(yaml_file_path: str) -> dict: global_config.setdefault("stop_ved_0_output", False) global_config.setdefault("db_char_set", "latin-1") - # 14) Logfilnavn formateres med dato - global_config["logfil"] = str(global_config["logfil"]).format( - yy=global_config["dato"].strftime("%y"), - yyyy=global_config["dato"].strftime("%Y"), - mm=global_config["dato"].strftime("%m"), - dd=global_config["dato"].strftime("%d"), + # 14) Logfilnavn formateres med dato og placeres i output_path + global_config["logfil"] = os.path.join( + global_config["output_path"], + str(global_config["logfil"]).format( + yy=global_config["dato"].strftime("%y"), + yyyy=global_config["dato"].strftime("%Y"), + mm=global_config["dato"].strftime("%m"), + dd=global_config["dato"].strftime("%d"), + ) ) # 15) Timestamp diff --git a/udpak_semistruktur/ddl.py b/udpak_semistruktur/ddl.py index 6cc31d4..44cff2c 100644 --- a/udpak_semistruktur/ddl.py +++ b/udpak_semistruktur/ddl.py @@ -278,6 +278,10 @@ def generate_insert_move_sql_short(table_name_from_yaml: str, columns: List[str] def run_ddl_mode(args, config: Dict[str, Any], global_config: Dict[str, Any]) -> None: """ Executes the full DDL-only flow and writes files. + + --tmp flaget styrer om der genereres én eller to tabeller: + - Uden --tmp: kun den tabel der er nævnt i YAML + - Med --tmp: både den nævnte tabel og dens modpart (base↔tmp) """ outdir = os.path.join(global_config["output_path"], "sql") @@ -296,78 +300,72 @@ def run_ddl_mode(args, config: Dict[str, Any], global_config: Dict[str, Any]) -> try: base_tabel, tmp_tabel = split_base_tmp(tabel) yaml_is_tmp = tabel.lower().endswith("_tmp") + skal_lave_tmp = bool(getattr(args, "tmp", False)) # --------------------------------------------------------- - # 1) Base DDL (always) + # 1) Primær DDL – brug tabelnavnet præcis som det er i YAML # --------------------------------------------------------- - base_conf = deepcopy(file_conf) - base_conf["tabel_navn"] = base_tabel + primær_conf = deepcopy(file_conf) + ddl_sql_primær = generate_create_table_sql(primær_conf, global_config) + samlet_sql_indhold.append(f"-- Tabel: {tabel}\n{ddl_sql_primær}\n") - ddl_sql_base = generate_create_table_sql(base_conf, global_config) - samlet_sql_indhold.append(f"-- Tabel: {base_tabel}\n{ddl_sql_base}\n") + ddl_primær_name = file_conf.get("ddl_fil_navn") or _default_ddl_filename(tabel) + ddl_primær_name = generer_filnavn(ddl_primær_name, global_config) + ddl_primær_path = os.path.join(outdir, ddl_primær_name) - # If YAML already is base, respect ddl_fil_navn if present. - # If YAML is tmp (base differs), don't reuse ddl_fil_navn blindly. - if (not yaml_is_tmp) and file_conf.get("ddl_fil_navn"): - ddl_base_name = file_conf["ddl_fil_navn"] - else: - ddl_base_name = _default_ddl_filename(base_tabel) + with open(ddl_primær_path, "w", encoding="utf-8") as f: + f.write(ddl_sql_primær) - ddl_base_name = generer_filnavn(ddl_base_name, global_config) - ddl_base_path = os.path.join(outdir, ddl_base_name) - - with open(ddl_base_path, "w", encoding="utf-8") as f: - f.write(ddl_sql_base) - - logger.info(f"[DDL] Skrev {ddl_base_path}") + logger.info(f"[DDL] Skrev {ddl_primær_path}") antal += 1 # --------------------------------------------------------- - # 2) TMP DDL - # - If YAML is _tmp: ALWAYS generate tmp too - # - Else: only when --tmp is set + # 2) Sekundær DDL – kun hvis --tmp er givet + # YAML er base → sekundær er tmp + # YAML er _tmp → sekundær er base # --------------------------------------------------------- - skal_lave_tmp = yaml_is_tmp or bool(getattr(args, "tmp", False)) + if skal_lave_tmp: - tmp_conf = deepcopy(file_conf) - tmp_conf["tabel_navn"] = tmp_tabel + sekundær_tabel = tmp_tabel if not yaml_is_tmp else base_tabel + sekundær_conf = deepcopy(file_conf) + sekundær_conf["tabel_navn"] = sekundær_tabel - ddl_sql_tmp = generate_create_table_sql(tmp_conf, global_config) - samlet_sql_indhold.append(f"-- Tabel: {tmp_tabel}\n{ddl_sql_tmp}\n") + ddl_sql_sekundær = generate_create_table_sql(sekundær_conf, global_config) + samlet_sql_indhold.append(f"-- Tabel: {sekundær_tabel}\n{ddl_sql_sekundær}\n") - ddl_tmp_name = _default_ddl_filename(tmp_tabel) - ddl_tmp_name = generer_filnavn(ddl_tmp_name, global_config) - ddl_tmp_path = os.path.join(outdir, ddl_tmp_name) + ddl_sekundær_name = generer_filnavn(_default_ddl_filename(sekundær_tabel), global_config) + ddl_sekundær_path = os.path.join(outdir, ddl_sekundær_name) - with open(ddl_tmp_path, "w", encoding="utf-8") as f_tmp: - f_tmp.write(ddl_sql_tmp) + with open(ddl_sekundær_path, "w", encoding="utf-8") as f: + f.write(ddl_sql_sekundær) - logger.info(f"[DDL] Skrev {ddl_tmp_path}") + logger.info(f"[DDL] Skrev {ddl_sekundær_path}") antal += 1 # --------------------------------------------------------- - # 3) Flyt scripts (Sybase ASE) if --flyt - # Always based on YAML table name, which determines base/tmp + # 3) Flyt scripts – kun hvis --flyt er givet # --------------------------------------------------------- if getattr(args, "flyt", False): - _skriv_flyt_scripts(tabel, base_tabel, tmp_tabel, file_conf, outdir, - generate_insert_move_sql, samlet_flyt_indhold) + _skriv_flyt_scripts( + tabel, base_tabel, tmp_tabel, file_conf, outdir, + generate_insert_move_sql, samlet_flyt_indhold + ) # --------------------------------------------------------- - # 4) Flyt scripts (Sybase ASE) if --flyt_kort - # Always based on YAML table name, which determines base/tmp + # 4) Flyt scripts kort – kun hvis --flyt_kort er givet # --------------------------------------------------------- if getattr(args, "flyt_kort", False): - _skriv_flyt_scripts(tabel, base_tabel, tmp_tabel, file_conf, outdir, - generate_insert_move_sql_short, samlet_flyt_indhold) - + _skriv_flyt_scripts( + tabel, base_tabel, tmp_tabel, file_conf, outdir, + generate_insert_move_sql_short, samlet_flyt_indhold + ) except Exception as e: logger.error(f"[DDL] Fejl for {tabel}: {e}") raise # --------------------------------------------------------- - # Combined files + # Samlede filer # --------------------------------------------------------- if antal > 0: samlet_sti = os.path.join(outdir, "sql_samlet.sql") @@ -375,7 +373,7 @@ def run_ddl_mode(args, config: Dict[str, Any], global_config: Dict[str, Any]) -> f_alt.write("\n".join(samlet_sql_indhold)) logger.info(f"[DDL] Skrev samlet fil: {samlet_sti}") - if (getattr(args, "flyt", False) or getattr(args, "flyt_kort", False)) and len(samlet_flyt_indhold) > 0: + if (getattr(args, "flyt", False) or getattr(args, "flyt_kort", False)) and samlet_flyt_indhold: samlet_flyt_sti = os.path.join(outdir, "sql_flyt_samlet.sql") with open(samlet_flyt_sti, "w", encoding="utf-8") as f_flyt_alt: f_flyt_alt.write("\n".join(samlet_flyt_indhold)) diff --git a/udpak_semistruktur/extract/extractor.py b/udpak_semistruktur/extract/extractor.py index c249919..a33a058 100644 --- a/udpak_semistruktur/extract/extractor.py +++ b/udpak_semistruktur/extract/extractor.py @@ -8,6 +8,7 @@ from udpak_semistruktur.extract.traversal import ( hent_objekt_fra_sti, _resolve_with_indices, matcher_hvis_findes, + _extract_text_node, ) logger = hent_logger(__name__) @@ -33,7 +34,7 @@ def udvid_rod_alternativer(rod: str) -> list[dict]: for choice in choices ] -def hent_fra_spec(spec, default_el, json_data, sti_index, global_config) -> tuple[Any, bool]: +def hent_fra_spec(spec, default_el, json_data, sti_index, global_config, returner_liste=False) -> tuple[Any, bool]: """Henter en værdi fra json_data baseret på en spec-definition. Returnerer (værdi, mangler).""" if spec is None: @@ -54,7 +55,7 @@ def hent_fra_spec(spec, default_el, json_data, sti_index, global_config) -> tupl if felt == ".": return default_el, False - # Vælg rod/base + # Vælg rod/base if rod: parent_obj = hent_objekt_fra_sti(json_data, rod, sti_index) if not isinstance(parent_obj, (dict, list)): @@ -63,9 +64,23 @@ def hent_fra_spec(spec, default_el, json_data, sti_index, global_config) -> tupl else: if not isinstance(default_el, (dict, list)): return None, True - # base_path er ukendt her; brug tom – sti_index-nøgler vil stadig ramme korrekt, - # når felt-stien selv rammer lister (acc akkumuleres fra feltet). - kandidat = _resolve_with_indices(default_el, felt, sti_index, base_path="") + + if returner_liste: + # Brug rekursiv_udpakning til at samle alle værdier langs stien + # fx felt="linjer.produkt" giver ["Produkt A", "Produkt B"] + resultater = [] + for element, _ in rekursiv_udpakning(default_el, felt): + if element not in EMPTY_SENTINELS: + resultater.append(_extract_text_node(element)) + return resultater if resultater else None, False + else: + kandidat = _resolve_with_indices(default_el, felt, sti_index, base_path="") + + # Hvis kandidaten er en liste og vi ikke ønsker at returnere hele listen, + # vælg det aktuelle element via sti_index som normalt. + # Hvis returner_liste=True, returneres hele listen uberørt til fx join. + if isinstance(kandidat, list) and not returner_liste: + kandidat = kandidat[0] if kandidat else None missing = kandidat is None and not isinstance(default_el, list) return kandidat, missing @@ -80,10 +95,15 @@ def hent_kolonne_værdi_med_fallback(kol: dict, el: Any, json_data: Any, sti_ind værdi = evaluer_værdi_token(raw_value, global_config) if raw_value is not None else None missing = raw_value is None else: + returner_liste = bool(kol.get("join", False) or kol.get("flatten", False)) + print(f"DEBUG kolonne={kolnavn}, join={kol.get('join')}, flatten={kol.get('flatten')}, returner_liste={returner_liste}") primær_spec = {"felt": kol["felt"]} if kol.get("rod"): primær_spec["rod"] = kol["rod"] - værdi, missing = hent_fra_spec(primær_spec, el, json_data, sti_index, global_config) + værdi, missing = hent_fra_spec( + primær_spec, el, json_data, sti_index, global_config, + returner_liste=returner_liste + ) # 2) Hvis missing -> prøv missing_fallback if missing: diff --git a/udpak_semistruktur/extract/traversal.py b/udpak_semistruktur/extract/traversal.py index 38a006f..4ecc627 100644 --- a/udpak_semistruktur/extract/traversal.py +++ b/udpak_semistruktur/extract/traversal.py @@ -15,7 +15,7 @@ def _extract_text_node(v: Any) -> Any: return v["#text"] return v -def _resolve_with_indices(obj: Any, path: str, sti_index: dict, base_path: str = "") -> Any: +def _resolve_with_indices(obj: Any, path: str, sti_index: dict, base_path: str = "", returner_liste: bool = False) -> Any: """ Følger en dot-sti og bruger sti_index til at vælge elementer hver gang vi rammer en liste. base_path er den allerede-resolverede @@ -60,12 +60,14 @@ def _resolve_with_indices(obj: Any, path: str, sti_index: dict, base_path: str = return None # Hvis vi ender på en liste, vælg index én sidste gang - if isinstance(cur, list): + if isinstance(cur, list) and not returner_liste: idx = sti_index.get(acc, 0) try: cur = cur[idx] except (TypeError, IndexError): return None + elif isinstance(cur, list) and returner_liste: + return _extract_text_node(cur) return _extract_text_node(cur) diff --git a/udpak_semistruktur/logger.py b/udpak_semistruktur/logger.py index d73699e..c5b6bda 100644 --- a/udpak_semistruktur/logger.py +++ b/udpak_semistruktur/logger.py @@ -13,23 +13,33 @@ LOG_NIVEAUER = { "critical": logging.CRITICAL, } -def opsaet_logging(log_fil: str, niveau: str = "info") -> None: +def opsaet_logging(log_fil: str, niveau: str = "info", log_output: str = "begge") -> None: """ - Opsætter global logging til både fil og konsol. - niveau styres fra config (debug/info/warning/error/critical). + Opsætter global logging til fil og/eller konsol. + log_output styres fra config: 'fil', 'konsol' eller 'begge'. + niveau styres fra config: debug/info/warning/error/critical. """ log_niveau = LOG_NIVEAUER.get(niveau.lower(), logging.INFO) - os.makedirs(os.path.dirname(log_fil), exist_ok=True) + handlers = [] + + if log_output in ("fil", "begge"): + log_mappe = os.path.dirname(log_fil) + if log_mappe: + os.makedirs(log_mappe, exist_ok=True) + handlers.append(logging.FileHandler(log_fil, encoding="utf-8")) + + if log_output in ("konsol", "begge"): + handlers.append(logging.StreamHandler()) + + if not handlers: + handlers.append(logging.StreamHandler()) # fallback så vi aldrig er helt tavse logging.basicConfig( level=log_niveau, format=fmt, datefmt=datefmt, - handlers=[ - logging.FileHandler(log_fil, encoding="utf-8"), - logging.StreamHandler(), - ] + handlers=handlers, ) def hent_logger(navn: str) -> logging.Logger: diff --git a/udpak_semistruktur/transform/clean.py b/udpak_semistruktur/transform/clean.py index 002c2e0..4bc5ac8 100644 --- a/udpak_semistruktur/transform/clean.py +++ b/udpak_semistruktur/transform/clean.py @@ -18,7 +18,7 @@ def fjern_linjeskift(data: dict, file_config: dict, global_config: dict) -> dict for række in data["rækker"]: værdi = række.get(kolonnenavn) if isinstance(værdi, str): - værdi_ny = værdi.replace("\r\n", "").replace("\n", "").replace("\r", "") + værdi_ny = værdi.replace("\r\n", " ").replace("\n", " ").replace("\r", " ") else: værdi_ny = værdi diff --git a/udpak_semistruktur/transform/convert.py b/udpak_semistruktur/transform/convert.py index 69df4cc..ccf8fb3 100644 --- a/udpak_semistruktur/transform/convert.py +++ b/udpak_semistruktur/transform/convert.py @@ -166,7 +166,7 @@ def konverter(data: dict, file_config: dict, global_config: dict) -> dict: tmp = value[:string_truncate] else: tmp = value - elif field_type in ["hash", "id", "file", "rod_variant"]: + elif field_type in ["hash", "id", "file", "rod_variant", "rod_path"]: tmp = value else: raise ValueError(f"Ukendt datatype '{field_type}' for feltet '{kolonnenavn}'.")