Files
ss-tools/backend/src/plugins/translate/service_target_schema.py
busya 525e0a56af fix(translate): add database_id to datasources response + auto-select on frontend
Root cause: fetch_available_datasources did not return database_id,
so frontend could not auto-set target_database_id when user selected
a datasource. User then had to manually pick a database on Target Config
tab — and could accidentally select the wrong one.

Backend changes:
- Add 'database_id' field to datasources response (from db_info.get('id'))
- Add _database_name tracking in SupersetSqlLabExecutor with getter
- Add database_name + database_backend to TargetSchemaValidationResponse
- Enrich resolve_database_id logging with database_name and all_keys

Frontend changes:
- In selectDatasource(), auto-set targetDatabaseId from ds.database_id
  when present, so the correct target DB is used for schema checks.
2026-05-29 15:36:36 +03:00

353 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# #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 ""
db_name = executor.get_database_name() or "unknown"
safe_schema = (req.target_schema or "public").replace("'", "''")
safe_table = req.target_table.replace("'", "''")
# Нормализуем backend через маппинг (как в get_dialect_from_database)
# чтобы правильно обработать "clickhousedb" → "clickhouse",
# "greenplum" → "postgresql" и другие варианты
_backend_normalize = {
"clickhouse": "clickhouse",
"clickhousedb": "clickhouse",
"postgresql": "postgresql",
"greenplum": "postgresql",
"mysql": "mysql",
"mssql": "mssql",
"sqlite": "sqlite",
"oracle": "oracle",
"snowflake": "snowflake",
"bigquery": "bigquery",
"redshift": "redshift",
"presto": "presto",
"trino": "trino",
"druid": "druid",
"hive": "hive",
"spark": "spark",
"databricks": "databricks",
}
normalized_backend = _backend_normalize.get(backend.lower().strip(), backend.lower().strip())
# ClickHouse использует system.columns, всё остальное — information_schema.columns
is_clickhouse = normalized_backend == "clickhouse"
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, "database_name": db_name}})
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,
database_name=db_name, database_backend=backend,
)
# Парсим 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,
database_name=db_name, database_backend=backend,
)
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,
database_name=db_name, database_backend=backend,
)
# #endregion validate_target_table_schema
# #endregion TargetSchemaValidation