This commit is contained in:
2026-04-04 00:57:17 +02:00
parent 4f27849fd3
commit 0d5c19825d
10 changed files with 112 additions and 68 deletions

4
.gitignore vendored
View File

@@ -32,3 +32,7 @@ output/
# Windows # Windows
Thumbs.db Thumbs.db
desktop.ini desktop.ini
# tests
tests/

2
testenv.env Normal file
View File

@@ -0,0 +1,2 @@
env=dev
edw_hk_plan_ts=20260404

View File

@@ -32,7 +32,7 @@ def _byg_argument_parser() -> argparse.ArgumentParser:
def _kør_udtræk(config: dict, global_config: dict) -> None: def _kør_udtræk(config: dict, global_config: dict) -> None:
"""Kører den normale udtræks- og transformationspipeline.""" """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 = global_config.get("input_fil")
input_fil_liste = global_config.get("input_fil_liste") input_fil_liste = global_config.get("input_fil_liste")
@@ -120,9 +120,14 @@ def main():
opsaet_logging( opsaet_logging(
log_fil=global_config["logfil"], log_fil=global_config["logfil"],
niveau=global_config.get("log_niveau", "info"), niveau=global_config.get("log_niveau", "info"),
log_output=global_config.get("log_output", "begge"),
) )
# Eksekver DDL-flowet # 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): if ddl.is_enabled(args):
ddl.run_ddl_mode(args, config, global_config) ddl.run_ddl_mode(args, config, global_config)
else: else:

View File

@@ -354,13 +354,16 @@ def valider_yaml(yaml_file_path: str) -> dict:
global_config.setdefault("stop_ved_0_output", False) global_config.setdefault("stop_ved_0_output", False)
global_config.setdefault("db_char_set", "latin-1") global_config.setdefault("db_char_set", "latin-1")
# 14) Logfilnavn formateres med dato # 14) Logfilnavn formateres med dato og placeres i output_path
global_config["logfil"] = str(global_config["logfil"]).format( global_config["logfil"] = os.path.join(
global_config["output_path"],
str(global_config["logfil"]).format(
yy=global_config["dato"].strftime("%y"), yy=global_config["dato"].strftime("%y"),
yyyy=global_config["dato"].strftime("%Y"), yyyy=global_config["dato"].strftime("%Y"),
mm=global_config["dato"].strftime("%m"), mm=global_config["dato"].strftime("%m"),
dd=global_config["dato"].strftime("%d"), dd=global_config["dato"].strftime("%d"),
) )
)
# 15) Timestamp # 15) Timestamp
global_config["var_timestamp"] = datetime.now().strftime("%Y-%m-%dT%H:%M:%S.%f") global_config["var_timestamp"] = datetime.now().strftime("%Y-%m-%dT%H:%M:%S.%f")

View File

@@ -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: def run_ddl_mode(args, config: Dict[str, Any], global_config: Dict[str, Any]) -> None:
""" """
Executes the full DDL-only flow and writes files. 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") 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: try:
base_tabel, tmp_tabel = split_base_tmp(tabel) base_tabel, tmp_tabel = split_base_tmp(tabel)
yaml_is_tmp = tabel.lower().endswith("_tmp") 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) primær_conf = deepcopy(file_conf)
base_conf["tabel_navn"] = base_tabel 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) ddl_primær_name = file_conf.get("ddl_fil_navn") or _default_ddl_filename(tabel)
samlet_sql_indhold.append(f"-- Tabel: {base_tabel}\n{ddl_sql_base}\n") 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. with open(ddl_primær_path, "w", encoding="utf-8") as f:
# If YAML is tmp (base differs), don't reuse ddl_fil_navn blindly. f.write(ddl_sql_primær)
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)
ddl_base_name = generer_filnavn(ddl_base_name, global_config) logger.info(f"[DDL] Skrev {ddl_primær_path}")
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}")
antal += 1 antal += 1
# --------------------------------------------------------- # ---------------------------------------------------------
# 2) TMP DDL # 2) Sekundær DDL kun hvis --tmp er givet
# - If YAML is _tmp: ALWAYS generate tmp too # YAML er base → sekundær er tmp
# - Else: only when --tmp is set # YAML er _tmp → sekundær er base
# --------------------------------------------------------- # ---------------------------------------------------------
skal_lave_tmp = yaml_is_tmp or bool(getattr(args, "tmp", False))
if skal_lave_tmp: if skal_lave_tmp:
tmp_conf = deepcopy(file_conf) sekundær_tabel = tmp_tabel if not yaml_is_tmp else base_tabel
tmp_conf["tabel_navn"] = tmp_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) ddl_sql_sekundær = generate_create_table_sql(sekundær_conf, global_config)
samlet_sql_indhold.append(f"-- Tabel: {tmp_tabel}\n{ddl_sql_tmp}\n") samlet_sql_indhold.append(f"-- Tabel: {sekundær_tabel}\n{ddl_sql_sekundær}\n")
ddl_tmp_name = _default_ddl_filename(tmp_tabel) ddl_sekundær_name = generer_filnavn(_default_ddl_filename(sekundær_tabel), global_config)
ddl_tmp_name = generer_filnavn(ddl_tmp_name, global_config) ddl_sekundær_path = os.path.join(outdir, ddl_sekundær_name)
ddl_tmp_path = os.path.join(outdir, ddl_tmp_name)
with open(ddl_tmp_path, "w", encoding="utf-8") as f_tmp: with open(ddl_sekundær_path, "w", encoding="utf-8") as f:
f_tmp.write(ddl_sql_tmp) 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 antal += 1
# --------------------------------------------------------- # ---------------------------------------------------------
# 3) Flyt scripts (Sybase ASE) if --flyt # 3) Flyt scripts kun hvis --flyt er givet
# Always based on YAML table name, which determines base/tmp
# --------------------------------------------------------- # ---------------------------------------------------------
if getattr(args, "flyt", False): if getattr(args, "flyt", False):
_skriv_flyt_scripts(tabel, base_tabel, tmp_tabel, file_conf, outdir, _skriv_flyt_scripts(
generate_insert_move_sql, samlet_flyt_indhold) tabel, base_tabel, tmp_tabel, file_conf, outdir,
generate_insert_move_sql, samlet_flyt_indhold
)
# --------------------------------------------------------- # ---------------------------------------------------------
# 4) Flyt scripts (Sybase ASE) if --flyt_kort # 4) Flyt scripts kort kun hvis --flyt_kort er givet
# Always based on YAML table name, which determines base/tmp
# --------------------------------------------------------- # ---------------------------------------------------------
if getattr(args, "flyt_kort", False): if getattr(args, "flyt_kort", False):
_skriv_flyt_scripts(tabel, base_tabel, tmp_tabel, file_conf, outdir, _skriv_flyt_scripts(
generate_insert_move_sql_short, samlet_flyt_indhold) tabel, base_tabel, tmp_tabel, file_conf, outdir,
generate_insert_move_sql_short, samlet_flyt_indhold
)
except Exception as e: except Exception as e:
logger.error(f"[DDL] Fejl for {tabel}: {e}") logger.error(f"[DDL] Fejl for {tabel}: {e}")
raise raise
# --------------------------------------------------------- # ---------------------------------------------------------
# Combined files # Samlede filer
# --------------------------------------------------------- # ---------------------------------------------------------
if antal > 0: if antal > 0:
samlet_sti = os.path.join(outdir, "sql_samlet.sql") 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)) f_alt.write("\n".join(samlet_sql_indhold))
logger.info(f"[DDL] Skrev samlet fil: {samlet_sti}") 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") samlet_flyt_sti = os.path.join(outdir, "sql_flyt_samlet.sql")
with open(samlet_flyt_sti, "w", encoding="utf-8") as f_flyt_alt: with open(samlet_flyt_sti, "w", encoding="utf-8") as f_flyt_alt:
f_flyt_alt.write("\n".join(samlet_flyt_indhold)) f_flyt_alt.write("\n".join(samlet_flyt_indhold))

View File

@@ -8,6 +8,7 @@ from udpak_semistruktur.extract.traversal import (
hent_objekt_fra_sti, hent_objekt_fra_sti,
_resolve_with_indices, _resolve_with_indices,
matcher_hvis_findes, matcher_hvis_findes,
_extract_text_node,
) )
logger = hent_logger(__name__) logger = hent_logger(__name__)
@@ -33,7 +34,7 @@ def udvid_rod_alternativer(rod: str) -> list[dict]:
for choice in choices 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).""" """Henter en værdi fra json_data baseret på en spec-definition. Returnerer (værdi, mangler)."""
if spec is None: if spec is None:
@@ -63,10 +64,24 @@ def hent_fra_spec(spec, default_el, json_data, sti_index, global_config) -> tupl
else: else:
if not isinstance(default_el, (dict, list)): if not isinstance(default_el, (dict, list)):
return None, True 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). 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="") 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) missing = kandidat is None and not isinstance(default_el, list)
return kandidat, missing 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 værdi = evaluer_værdi_token(raw_value, global_config) if raw_value is not None else None
missing = raw_value is None missing = raw_value is None
else: 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"]} primær_spec = {"felt": kol["felt"]}
if kol.get("rod"): if kol.get("rod"):
primær_spec["rod"] = kol["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 # 2) Hvis missing -> prøv missing_fallback
if missing: if missing:

View File

@@ -15,7 +15,7 @@ def _extract_text_node(v: Any) -> Any:
return v["#text"] return v["#text"]
return v 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 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 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 return None
# Hvis vi ender på en liste, vælg index én sidste gang # 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) idx = sti_index.get(acc, 0)
try: try:
cur = cur[idx] cur = cur[idx]
except (TypeError, IndexError): except (TypeError, IndexError):
return None return None
elif isinstance(cur, list) and returner_liste:
return _extract_text_node(cur)
return _extract_text_node(cur) return _extract_text_node(cur)

View File

@@ -13,23 +13,33 @@ LOG_NIVEAUER = {
"critical": logging.CRITICAL, "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. Opsætter global logging til fil og/eller konsol.
niveau styres fra config (debug/info/warning/error/critical). 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) 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( logging.basicConfig(
level=log_niveau, level=log_niveau,
format=fmt, format=fmt,
datefmt=datefmt, datefmt=datefmt,
handlers=[ handlers=handlers,
logging.FileHandler(log_fil, encoding="utf-8"),
logging.StreamHandler(),
]
) )
def hent_logger(navn: str) -> logging.Logger: def hent_logger(navn: str) -> logging.Logger:

View File

@@ -166,7 +166,7 @@ def konverter(data: dict, file_config: dict, global_config: dict) -> dict:
tmp = value[:string_truncate] tmp = value[:string_truncate]
else: else:
tmp = value tmp = value
elif field_type in ["hash", "id", "file", "rod_variant"]: elif field_type in ["hash", "id", "file", "rod_variant", "rod_path"]:
tmp = value tmp = value
else: else:
raise ValueError(f"Ukendt datatype '{field_type}' for feltet '{kolonnenavn}'.") raise ValueError(f"Ukendt datatype '{field_type}' for feltet '{kolonnenavn}'.")