# #region TargetSchemaValidation [C:4] [TYPE Module] [SEMANTICS translate, schema, validation, target-table] # @BRIEF Проверка схемы целевой таблицы: запрос колонок через Superset SQL Lab, # сравнение с ожидаемыми (из build_columns), возврат diff. # @LAYER Service # @RELATION DEPENDS_ON -> [SupersetSqlLabExecutor] # @RELATION DEPENDS_ON -> [EXT:method:schemas.translate.TargetSchemaValidationRequest] # @RELATION DEPENDS_ON -> [EXT:method:schemas.translate.TargetSchemaValidationResponse] # @PRE Superset окружение доступно, target_database_id валиден. # @POST Возвращает актуальные, ожидаемые, отсутствующие и лишние колонки. # @SIDE_EFFECT Выполняет SQL-запрос через Superset SQL Lab. # @RATIONALE C4 — оркестрация: вызов executor, парсинг ответа, diff и построение ответа. # #endregion TargetSchemaValidation import re from typing import Any from ...core.config_manager import ConfigManager from ...core.logger import belief_scope, logger from ...schemas.translate import ( TargetSchemaColumnInfo, TargetSchemaValidationRequest, TargetSchemaValidationResponse, ) from .superset_executor import SupersetSqlLabExecutor _TABLE_NAME_RE = re.compile(r'^[a-zA-Z_][a-zA-Z0-9_]*$') # #region _build_expected_columns [C:2] [TYPE Function] # @BRIEF Собирает список ожидаемых колонок по конфигурации column mapping. def _build_expected_columns(req: TargetSchemaValidationRequest) -> list[TargetSchemaColumnInfo]: """Определяет, какие колонки INSERT будет пытаться заполнить.""" names: list[str] = [] if req.target_key_cols: names.extend(req.target_key_cols) effective_target = req.target_column or req.translation_column if effective_target: names.append(effective_target) if req.target_language_column: names.append(req.target_language_column) if req.target_source_column: names.append(req.target_source_column) if req.target_source_language_column: names.append(req.target_source_language_column) names.append("context") names.append("is_original") seen: set[str] = set() deduped: list[str] = [] for c in names: if c and c not in seen: deduped.append(c) seen.add(c) return [TargetSchemaColumnInfo(name=n) for n in deduped] # #endregion _build_expected_columns # #region _extract_columns_from_rows [C:2] [TYPE Function] # @BRIEF Извлекает список колонок таблицы из data-строк результата SQL Lab. # # Для PostgreSQL (information_schema.columns) каждая строка содержит: # {"column_name": "id", "data_type": "integer", "is_nullable": "YES"} # # Для ClickHouse (DESCRIBE TABLE) каждая строка содержит: # {"name": "id", "type": "Int32"} # # Функция ищет имя колонки по нескольким возможным ключам и возвращает # унифицированный список TargetSchemaColumnInfo. def _extract_columns_from_rows(data_rows: list[dict[str, Any]]) -> list[dict[str, Any]]: """ Извлекает информацию о колонках таблицы из data-строк SQL Lab ответа. """ columns_info: list[dict[str, Any]] = [] for row in data_rows: if not isinstance(row, dict): continue # Имя колонки: ищем по всем возможным ключам (PG, CH, разные версии Superset) col_name = ( row.get("column_name") # PostgreSQL information_schema or row.get("name") # ClickHouse DESCRIBE TABLE / MySQL SHOW COLUMNS or row.get("Field") # MySQL SHOW COLUMNS ) if not col_name: continue # Тип данных col_type = ( row.get("data_type") # PG information_schema or row.get("type") # ClickHouse DESCRIBE TABLE or row.get("Type") # MySQL SHOW COLUMNS ) # Nullable is_nullable = row.get("is_nullable") # PG if isinstance(is_nullable, str): is_nullable = is_nullable.upper() == "YES" elif not isinstance(is_nullable, bool): is_nullable = True # ClickHouse не возвращает is_nullable, считаем True columns_info.append({ "name": str(col_name), "type": str(col_type) if col_type else None, "is_nullable": is_nullable, }) return columns_info # #endregion _extract_columns_from_rows # #region _parse_sqllab_result [C:2] [TYPE Function] # @BRIEF Извлекает data-строки из ответа Superset SQL Lab. # Поддерживает 3 формата: sync mode, async polling, get_query_results. def _parse_sqllab_result(result: dict[str, Any]) -> tuple[list[dict[str, Any]], bool]: """ Извлекает строки данных из ответа SQL Lab. Returns (data_rows, table_exists). """ if not result: return [], False raw = result.get("raw_response", result) if not raw: return [], False data_rows = None # Формат 1: прямой ответ с data if "data" in raw and isinstance(raw["data"], list): data_rows = raw["data"] # Формат 2: вложенный result (async polling) elif "result" in raw and isinstance(raw["result"], dict): res = raw["result"] data_rows = res.get("data") if isinstance(res.get("data"), list) else None # Формат 3: ключ results (get_query_results) elif "results" in raw and isinstance(raw["results"], dict): res = raw["results"] if "data" in res and isinstance(res["data"], list): data_rows = res["data"] elif isinstance(res.get("result"), dict): sub = res["result"] data_rows = sub.get("data") if isinstance(sub.get("data"), list) else None # Формат 4: results — список строк (execute_and_poll sync mode возвращает так) elif "results" in raw and isinstance(raw["results"], list): data_rows = raw["results"] if data_rows is None: return [], False table_exists = len(data_rows) > 0 # Нормализуем формат строк: если data_rows — список списков, а не словарей # (бывает в некоторых версиях Superset), конвертируем через columns metadata if data_rows and not isinstance(data_rows[0], dict): columns_raw = raw.get("columns") or (raw.get("result") or {}).get("columns") or [] col_names = [] for c in columns_raw: cn = c.get("name") or c.get("column_name") or "" if cn: col_names.append(cn) if col_names: normalized = [] for row in data_rows: if isinstance(row, list) and len(row) == len(col_names): normalized.append(dict(zip(col_names, row))) data_rows = normalized return data_rows, table_exists # #endregion _parse_sqllab_result # #region validate_target_table_schema [C:4] [TYPE Function] [SEMANTICS translate, schema, validate, orchestrate] # @BRIEF Основная функция: проверяет схему целевой таблицы через Superset SQL Lab. # @PRE Superset окружение и target_database_id валидны. # @POST Возвращает TargetSchemaValidationResponse с diff-анализом. # @SIDE_EFFECT Выполняет SQL-запрос к information_schema через Superset SQL Lab API. # @RELATION DEPENDS_ON -> [_build_expected_columns] # @RELATION DEPENDS_ON -> [_extract_columns_from_rows] # @RELATION DEPENDS_ON -> [_parse_sqllab_result] # @RELATION DEPENDS_ON -> [SupersetSqlLabExecutor] def validate_target_table_schema( req: TargetSchemaValidationRequest, config_manager: ConfigManager, ) -> TargetSchemaValidationResponse: """ Проверяет схему целевой таблицы: 1. Вычисляет ожидаемые колонки 2. Запрашивает актуальные через Superset SQL Lab 3. Сравнивает и возвращает diff """ with belief_scope("validate_target_table_schema"): # 1. Ожидаемые колонки expected = _build_expected_columns(req) expected_names = {c.name for c in expected} logger.reason("Built expected columns", extra={"payload": {"count": len(expected), "columns": [c.name for c in expected]}}) # 2. Валидация имён (SQL injection protection) if req.target_table and not _TABLE_NAME_RE.match(req.target_table): logger.explore("Invalid target table name rejected", extra={"payload": {"table": req.target_table}, "error": "Invalid characters in table name"}) return TargetSchemaValidationResponse( table_exists=False, error=f"Invalid target table name: '{req.target_table}'. Only alphanumeric and underscore allowed.", expected_columns=expected, actual_columns=[], missing_columns=expected, extra_columns=[], all_present=False, ) if req.target_schema and not _TABLE_NAME_RE.match(req.target_schema): logger.explore("Invalid target schema name rejected", extra={"payload": {"schema": req.target_schema}, "error": "Invalid characters in schema name"}) return TargetSchemaValidationResponse( table_exists=False, error=f"Invalid target schema name: '{req.target_schema}'. Only alphanumeric and underscore allowed.", expected_columns=expected, actual_columns=[], missing_columns=expected, extra_columns=[], all_present=False, ) # 3. Запрос через Superset SQL Lab target_ref = f"{req.target_schema or 'public'}.{req.target_table}" logger.reason("Querying target table schema", extra={"payload": {"table": target_ref, "env": req.environment_id, "db_id": req.target_database_id}}) try: executor = SupersetSqlLabExecutor(config_manager, req.environment_id) executor.resolve_database_id(target_database_id=req.target_database_id) backend = executor.get_database_backend() or "" safe_schema = (req.target_schema or "public").replace("'", "''") safe_table = req.target_table.replace("'", "''") # ClickHouse использует system.columns, всё остальное — information_schema.columns is_clickhouse = any(kw in backend.lower() for kw in ("clickhouse", "ch")) if is_clickhouse: sql = ( f"SELECT name, type, default_expression AS data_default " f"FROM system.columns " f"WHERE database = '{safe_schema}' " f"AND table = '{safe_table}' " f"ORDER BY position" ) else: sql = ( f"SELECT column_name, data_type, is_nullable " f"FROM information_schema.columns " f"WHERE table_schema = '{safe_schema}' " f"AND table_name = '{safe_table}' " f"ORDER BY ordinal_position" ) logger.reason("Executing SQL Lab query", extra={"payload": {"sql": sql[:200], "backend": backend}}) result = executor.execute_and_poll(sql=sql, max_polls=15, poll_interval_seconds=1.0) status = result.get("status", "") if status == "failed": error_msg = result.get("error_message", "SQL Lab query failed") logger.explore("SQL Lab query failed", extra={"payload": {"table": target_ref}, "error": error_msg}) return TargetSchemaValidationResponse( table_exists=False, error=f"Superset SQL Lab error: {error_msg}", expected_columns=expected, actual_columns=[], missing_columns=expected, extra_columns=[], all_present=False, ) # Парсим data-строки результата (реальные колонки таблицы) data_rows, table_exists = _parse_sqllab_result(result) actual_cols_raw = _extract_columns_from_rows(data_rows) actual_cols = [] for col in actual_cols_raw: actual_cols.append(TargetSchemaColumnInfo( name=col["name"], data_type=col.get("type"), is_nullable=col.get("is_nullable", True), )) actual_names = {c.name for c in actual_cols} # 4. Diff missing = [col for col in expected if col.name not in actual_names] extra = [col for col in actual_cols if col.name not in expected_names] logger.reflect("Schema check complete", extra={"payload": { "table": target_ref, "table_exists": table_exists, "expected": len(expected), "found": len(actual_cols), "missing": len(missing), "extra": len(extra), "all_present": len(missing) == 0, "actual_columns": [c.name for c in actual_cols], }}) return TargetSchemaValidationResponse( table_exists=table_exists, expected_columns=expected, actual_columns=actual_cols, missing_columns=missing, extra_columns=extra, all_present=len(missing) == 0, ) except Exception as e: logger.explore("Target schema validation failed", extra={"payload": {"table": target_ref, "env": req.environment_id}, "error": str(e)}) return TargetSchemaValidationResponse( table_exists=False, error=f"Failed to validate target schema: {e}", expected_columns=expected, actual_columns=[], missing_columns=expected, extra_columns=[], all_present=False, ) # #endregion validate_target_table_schema # #endregion TargetSchemaValidation