159 lines
7.4 KiB
Python
159 lines
7.4 KiB
Python
"""Независимая сверка лабораторной PXF: Postgres → сохраненный файл → STG/ODS."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import subprocess
|
|
from pathlib import Path
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
FIELDS = ("airport_code", "airport_name", "city", "country", "coordinates", "timezone")
|
|
JSON_FIELDS = {"airport_name", "city", "country"}
|
|
|
|
|
|
def read_rows(service: str, query: str) -> list[dict]:
|
|
"""Читает через psql в контейнере; настройки берет из окружения Compose."""
|
|
if service == "bookings-db":
|
|
command = (
|
|
'PGPASSWORD="$POSTGRES_PASSWORD" exec psql -X -qAt '
|
|
'-v ON_ERROR_STOP=1 -h 127.0.0.1 -U "$POSTGRES_USER" -d demo'
|
|
)
|
|
else:
|
|
command = (
|
|
'PGPASSWORD="$GREENPLUM_PASSWORD" exec '
|
|
"/usr/local/greenplum-db/bin/psql -X -qAt -v ON_ERROR_STOP=1 "
|
|
'-h 127.0.0.1 -U "$GREENPLUM_USER" -d "$GREENPLUM_DATABASE_NAME"'
|
|
)
|
|
result = subprocess.run(
|
|
["docker", "compose", "exec", "-T", service, "sh", "-c", command],
|
|
input=f"SELECT row_to_json(r) FROM ({query}) r;\n",
|
|
text=True,
|
|
capture_output=True,
|
|
cwd=ROOT,
|
|
check=True,
|
|
)
|
|
return [json.loads(line) for line in result.stdout.splitlines() if line.strip()]
|
|
|
|
|
|
def compare_snapshot(
|
|
expected: list[dict], actual: list[dict], batch_id: str, layer: str
|
|
) -> list[str]:
|
|
"""Проверяет ключи, все бизнес-поля и метки; порядок строк не важен."""
|
|
errors = []
|
|
if not expected:
|
|
return ["Контрольный источник пуст. Сначала восстановите Bookings."]
|
|
source = {row["airport_code"]: row for row in expected}
|
|
if len(source) != len(expected) or None in source or "" in source:
|
|
return ["В контрольном источнике пустые или повторяющиеся ключи."]
|
|
keys = [row["airport_code"] for row in actual]
|
|
if len(keys) != len(set(keys)):
|
|
errors.append("Повторяются airport_code внутри снимка.")
|
|
missing = source.keys() - set(keys)
|
|
extra = set(keys) - source.keys()
|
|
if missing:
|
|
errors.append(
|
|
f"Не получены ключи: {sorted(missing)[:5]} (всего {len(missing)})."
|
|
)
|
|
if extra:
|
|
errors.append(f"Лишние ключи: {sorted(map(str, extra))[:5]}.")
|
|
for row in actual:
|
|
key = row["airport_code"]
|
|
if row.get("_load_id") != batch_id:
|
|
errors.append(
|
|
f"{key}: _load_id не равен {batch_id}; проверьте выбор батча."
|
|
)
|
|
timestamps = ("_load_ts", "event_ts") if layer == "stg" else ("_load_ts",)
|
|
if any(not row.get(field) for field in timestamps):
|
|
errors.append(f"{key}: не заполнены метки времени {timestamps}.")
|
|
if key not in source:
|
|
continue
|
|
for field in FIELDS:
|
|
wanted, received = source[key][field], row[field]
|
|
if field in JSON_FIELDS:
|
|
# Сравниваем весь JSON в STG, включая языки, кроме русского.
|
|
wanted = json.loads(wanted) if wanted is not None else None
|
|
if layer == "ods":
|
|
wanted = wanted.get("ru") if wanted is not None else None
|
|
elif received is not None:
|
|
try:
|
|
received = json.loads(received)
|
|
except (ValueError, TypeError):
|
|
errors.append(f"{key}: {field} не содержит исходный JSON.")
|
|
continue
|
|
if received != wanted:
|
|
errors.append(f"{key}: отличается {field}.")
|
|
return errors
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
actions = parser.add_subparsers(dest="action", required=True)
|
|
export = actions.add_parser(
|
|
"export", help="Сохранить источник напрямую из Postgres"
|
|
)
|
|
export.add_argument(
|
|
"file", type=Path, help="Новый JSON-файл для контрольного источника"
|
|
)
|
|
check = actions.add_parser("check", help="Сверить снимок с сохраненным источником")
|
|
check.add_argument("batch_id", help="Run ID нужного запуска STG")
|
|
check.add_argument("file", type=Path, help="Контрольный JSON-файл из export")
|
|
check.add_argument("--layer", choices=("stg", "ods"), default="stg")
|
|
args = parser.parse_args()
|
|
|
|
try:
|
|
if args.action == "export":
|
|
fields = ", ".join(f"{field}::text AS {field}" for field in FIELDS)
|
|
rows = read_rows(
|
|
"bookings-db",
|
|
f"SELECT {fields} FROM bookings.airports_data ORDER BY airport_code",
|
|
)
|
|
if not rows:
|
|
raise ValueError(
|
|
"Источник пуст. Пройдите инициализацию Bookings из README."
|
|
)
|
|
rows.sort(key=lambda row: row["airport_code"])
|
|
# Не даем случайно заменить контроль A данными измененного источника B.
|
|
with args.file.open("x", encoding="utf-8") as output:
|
|
json.dump(rows, output, ensure_ascii=False, indent=2)
|
|
output.write("\n")
|
|
print(f"Источник: {len(rows)} аэропортов сохранено в {args.file}.")
|
|
return 0
|
|
|
|
expected = json.loads(args.file.read_text(encoding="utf-8"))
|
|
# Run ID передается SQL-литералом, а не частью команды shell.
|
|
batch_literal = "'" + args.batch_id.replace("'", "''") + "'"
|
|
query = f"SELECT * FROM {args.layer}.airports"
|
|
if args.layer == "stg":
|
|
query += f" WHERE _load_id = {batch_literal}"
|
|
actual = read_rows("greenplum", query)
|
|
errors = compare_snapshot(expected, actual, args.batch_id, args.layer)
|
|
if errors:
|
|
print("Сверка не прошла. Проверьте SQL загрузки и выбранный Run ID:")
|
|
for error in errors[:10]:
|
|
print(f"- {error}")
|
|
return 1
|
|
print(
|
|
f"Сверка пройдена: {args.layer}.airports, _load_id={args.batch_id}, "
|
|
f"{len(actual)} ключей; все бизнес-поля и метки совпадают с требованиями."
|
|
)
|
|
return 0
|
|
except subprocess.CalledProcessError as error:
|
|
print("Не удалось прочитать БД. Проверьте docker compose ps и настройки .env.")
|
|
print(error.stderr.strip())
|
|
return 1
|
|
except FileExistsError:
|
|
print(
|
|
"Контрольный файл уже существует. Сохраните его; для нового опыта выберите другой путь."
|
|
)
|
|
return 1
|
|
except (OSError, ValueError, KeyError, TypeError) as error:
|
|
print(
|
|
f"Не удалось выполнить сверку: {error}. Проверьте путь и формат контрольного файла."
|
|
)
|
|
return 1
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|