refactor(connections): remove ConnectionConfig, migrate mapper to SQL Lab

Backend:
- Remove ConnectionConfig model, CRUD routes (connections.py), and tests
- Remove ensure_connection_configs_table from database.py
- Migrate MapperPlugin from psycopg2 direct PostgreSQL to SupersetSqlLabExecutor
- Add DatasetMapper.get_sqllab_mappings() with default information_schema query
- Update MapColumnsRequest: connection_id → database_id + sql_query
- Fix pydantic_settings v2 deprecation (Field(env=...) → validation_alias)
- Clean up app.py, routes/__init__.py, database.py imports

Frontend:
- Remove DatabaseConnectionsTab, ConnectionForm, ConnectionList components
- Remove /settings/connections route page and Navbar link
- Remove connectionService.js, connections i18n files
- Update MapperTool.svelte: postgres → sqllab source
- Update Map Columns modal: database selector + SQL query instead of connection_id
- Clean up settings page tabs, nav links, test mocks
- Clean up i18n keys (settings, nav, mapper)

Tests: 25/25 pass (14 030-feature + 11 route tests)
Build: succeeds
This commit is contained in:
2026-05-21 18:47:41 +03:00
parent b529e8082a
commit 36643024cf
35 changed files with 269 additions and 1315 deletions

View File

@@ -1,21 +1,20 @@
# #region DatasetMapperModule [TYPE Module] [SEMANTICS dataset, superset, dataset-mapper]
# #region DatasetMapperModule [TYPE Module] [SEMANTICS dataset, superset, dataset-mapper, sqllab]
#
# @BRIEF Этот модуль отвечает за обновление метаданных (verbose_map) в датасетах Superset, извлекая их из PostgreSQL или XLSX-файлов.
# @BRIEF Этот модуль отвечает за обновление метаданных (verbose_name) в датасетах Superset,
# извлекая их из Superset SQL Lab (любая БД, подключённая к Superset) или XLSX-файлов.
# @LAYER: Domain
# @RELATION DEPENDS_ON -> backend.core.superset_client
# @RELATION DEPENDS_ON -> pandas
# @RELATION DEPENDS_ON -> psycopg2
# @PUBLIC_API: DatasetMapper
from typing import Any
import pandas as pd # type: ignore
import psycopg2 # type: ignore
from ..logger import belief_scope, logger as app_logger
# #region DatasetMapper [TYPE Class]
# @BRIEF Класс для меппинга и обновления verbose_map в датасетах Superset.
# @BRIEF Класс для маппинга и обновления verbose_name в датасетах Superset.
class DatasetMapper:
# #region __init__ [TYPE Function]
# @PURPOSE: Initializes the mapper.
@@ -23,77 +22,78 @@ class DatasetMapper:
def __init__(self):
pass
# #endregion __init__
# #region get_postgres_comments [TYPE Function]
# @PURPOSE: Извлекает комментарии к колонкам из системного каталога PostgreSQL.
# @PRE: db_config должен содержать валидные параметры подключения (host, port, user, password, dbname).
# @PRE: table_name и table_schema должны быть строками.
# @POST: Возвращается словарь, где ключи - имена колонок, значения - комментарии из БД.
# @THROW: Exception - При ошибках подключения или выполнения запроса к БД.
# @PARAM: db_config (Dict) - Конфигурация для подключения к БД.
# @PARAM: table_name (str) - Имя таблицы.
# @PARAM: table_schema (str) - Схема таблицы.
# @RETURN: Dict[str, str] - Словарь с комментариями к колонкам.
def get_postgres_comments(self, db_config: dict, table_name: str, table_schema: str) -> dict[str, str]:
with belief_scope("Fetch comments from PostgreSQL"):
app_logger.info("[get_postgres_comments][Enter] Fetching comments from PostgreSQL for %s.%s.", table_schema, table_name)
query = f"""
SELECT
cols.column_name,
CASE
WHEN pg_catalog.col_description(
(SELECT c.oid
FROM pg_catalog.pg_class c
JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
WHERE c.relname = cols.table_name
AND n.nspname = cols.table_schema),
cols.ordinal_position::int
) LIKE '%|%' THEN
split_part(
pg_catalog.col_description(
(SELECT c.oid
FROM pg_catalog.pg_class c
JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
WHERE c.relname = cols.table_name
AND n.nspname = cols.table_schema),
cols.ordinal_position::int
),
'|',
1
)
ELSE
pg_catalog.col_description(
(SELECT c.oid
FROM pg_catalog.pg_class c
JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
WHERE c.relname = cols.table_name
AND n.nspname = cols.table_schema),
cols.ordinal_position::int
)
END AS column_comment
FROM
information_schema.columns cols
WHERE cols.table_catalog = '{db_config.get('dbname')}' AND cols.table_name = '{table_name}' AND cols.table_schema = '{table_schema}';
"""
comments = {}
try:
with psycopg2.connect(**db_config) as conn, conn.cursor() as cursor:
cursor.execute(query)
for row in cursor.fetchall():
if row[1]:
comments[row[0]] = row[1]
app_logger.info("[get_postgres_comments][Success] Fetched %d comments.", len(comments))
except Exception as e:
app_logger.error("[get_postgres_comments][Failure] %s", e, exc_info=True)
raise
return comments
# #endregion get_postgres_comments
# #region get_sqllab_mappings [TYPE Function]
# @PURPOSE: Извлекает маппинги column_name -> verbose_name через SQL Lab Superset.
# @PRE: sqllab_executor должен быть инициализирован с database_id.
# @PRE: dataset_id должен существовать в Superset.
# @POST: Возвращается словарь column_name -> verbose_name из результатов SQL-запроса.
# Если sql_query не указан, строится запрос к information_schema.columns.
# @PARAM: client (SupersetClient) - Авторизованный клиент Superset.
# @PARAM: dataset_id (int) - ID датасета (для получения table_name/schema).
# @PARAM: sqllab_executor (SupersetSqlLabExecutor) - Исполнитель SQL Lab.
# @PARAM: database_id (int) - ID базы данных в Superset.
# @PARAM: sql_query (Optional[str]) - Произвольный SQL-запрос (должен вернуть column_name + verbose_name).
# @RETURN: Dict[str, str] - Словарь column_name -> verbose_name.
def get_sqllab_mappings(
self,
client: Any,
dataset_id: int,
sqllab_executor: Any,
database_id: int,
sql_query: str | None = None,
) -> dict[str, str]:
with belief_scope("Fetch mappings via SQL Lab"):
# Получаем датасет из Superset, чтобы узнать table_name и schema
dataset_response = client.get_dataset(dataset_id)
dataset_data = dataset_response.get("result", dataset_response)
table_name = dataset_data.get("table_name")
table_schema = dataset_data.get("schema") or "public"
database_name = dataset_data.get("database", {}).get("database_name", "")
app_logger.info(
"[get_sqllab_mappings] Dataset %d: %s.%s (db: %s)",
dataset_id, table_schema, table_name, database_name
)
# Если SQL-запрос не указан, строим дефолтный для information_schema
if not sql_query:
sql_query = (
"SELECT column_name, pg_catalog.col_description(\n"
" (quote_ident(table_schema) || '.' || quote_ident(table_name))::regclass::oid,\n"
" ordinal_position\n"
") AS verbose_name\n"
f"FROM information_schema.columns\n"
f"WHERE table_schema = '{table_schema}' AND table_name = '{table_name}';\n"
)
app_logger.info("[get_sqllab_mappings] Using default information_schema query for %s.%s", table_schema, table_name)
else:
app_logger.info("[get_sqllab_mappings] Using custom SQL query")
# Выполняем запрос через SQL Lab
app_logger.info("[get_sqllab_mappings] Executing SQL Lab query on database %d...", database_id)
result = sqllab_executor.execute_and_poll(sql_query, database_id)
rows = result.get("results") or result.get("data") or result.get("result", [])
app_logger.info("[get_sqllab_mappings] SQL Lab returned %d rows", len(rows))
mappings: dict[str, str] = {}
for row in rows:
col_name = row.get("column_name")
verbose_name = row.get("verbose_name")
if col_name and verbose_name:
mappings[col_name] = str(verbose_name)
app_logger.info("[get_sqllab_mappings] Extracted %d mappings", len(mappings))
return mappings
# #endregion get_sqllab_mappings
# #region load_excel_mappings [TYPE Function]
# @PURPOSE: Загружает меппинги 'column_name' -> 'column_comment' из XLSX файла.
# @PURPOSE: Загружает маппинги column_name -> verbose_name из XLSX файла.
# @PRE: file_path должен указывать на существующий XLSX файл.
# @POST: Возвращается словарь с меппингами из файла.
# @POST: Возвращается словарь с маппингами из файла.
# @THROW: Exception - При ошибках чтения файла или парсинга.
# @PARAM: file_path (str) - Путь к XLSX файлу.
# @RETURN: Dict[str, str] - Словарь с меппингами.
# @RETURN: Dict[str, str] - Словарь с маппингами.
def load_excel_mappings(self, file_path: str) -> dict[str, str]:
with belief_scope("Load mappings from Excel"):
app_logger.info("[load_excel_mappings][Enter] Loading mappings from %s.", file_path)
@@ -106,37 +106,48 @@ class DatasetMapper:
app_logger.error("[load_excel_mappings][Failure] %s", e, exc_info=True)
raise
# #endregion load_excel_mappings
# #region run_mapping [TYPE Function]
# @PURPOSE: Основная функция для выполнения меппинга и обновления verbose_map датасета в Superset.
# @PURPOSE: Основная функция для выполнения маппинга и обновления verbose_name датасета в Superset.
# @PRE: superset_client должен быть авторизован.
# @PRE: dataset_id должен быть существующим ID в Superset.
# @POST: Если найдены изменения, датасет в Superset обновлен через API.
# @RELATION: CALLS -> self.get_postgres_comments
# @RELATION: CALLS -> self.get_sqllab_mappings
# @RELATION: CALLS -> self.load_excel_mappings
# @RELATION: CALLS -> superset_client.get_dataset
# @RELATION: CALLS -> superset_client.update_dataset
# @PARAM: superset_client (Any) - Клиент Superset.
# @PARAM: dataset_id (int) - ID датасета для обновления.
# @PARAM: source (str) - Источник данных ('postgres', 'excel', 'both').
# @PARAM: postgres_config (Optional[Dict]) - Конфигурация для подключения к PostgreSQL.
# @PARAM: source (str) - Источник данных ('sqllab' или 'excel').
# @PARAM: sqllab_executor (Optional[Any]) - Исполнитель SQL Lab (для sqllab source).
# @PARAM: database_id (Optional[int]) - ID базы данных Superset (для sqllab source).
# @PARAM: sql_query (Optional[str]) - Произвольный SQL-запрос (для sqllab source).
# @PARAM: excel_path (Optional[str]) - Путь к XLSX файлу.
# @PARAM: table_name (Optional[str]) - Имя таблицы в PostgreSQL.
# @PARAM: table_schema (Optional[str]) - Схема таблицы в PostgreSQL.
def run_mapping(self, superset_client: Any, dataset_id: int, source: str, postgres_config: dict | None = None, excel_path: str | None = None, table_name: str | None = None, table_schema: str | None = None):
def run_mapping(
self,
superset_client: Any,
dataset_id: int,
source: str,
sqllab_executor: Any = None,
database_id: int | None = None,
sql_query: str | None = None,
excel_path: str | None = None,
):
with belief_scope(f"Run dataset mapping for ID {dataset_id}"):
app_logger.info("[run_mapping][Enter] Starting dataset mapping for ID %d from source '%s'.", dataset_id, source)
mappings: dict[str, str] = {}
try:
if source in ['postgres', 'both']:
assert postgres_config and table_name and table_schema, "Postgres config is required."
mappings.update(self.get_postgres_comments(postgres_config, table_name, table_schema))
if source in ['excel', 'both']:
assert excel_path, "Excel path is required."
if source == "sqllab":
assert sqllab_executor and database_id, "sqllab_executor and database_id are required for sqllab source."
mappings.update(self.get_sqllab_mappings(superset_client, dataset_id, sqllab_executor, database_id, sql_query))
elif source == "excel":
assert excel_path, "excel_path is required."
mappings.update(self.load_excel_mappings(excel_path))
if source not in ['postgres', 'excel', 'both']:
else:
app_logger.error("[run_mapping][Failure] Invalid source: %s.", source)
return
dataset_response = superset_client.get_dataset(dataset_id)
dataset_data = dataset_response['result']
@@ -171,6 +182,7 @@ class DatasetMapper:
changes_made = True
updated_columns.append(new_column)
updated_metrics = []
for metric in dataset_data.get("metrics", []):
new_metric = {
@@ -187,6 +199,7 @@ class DatasetMapper:
"uuid": metric.get("uuid"),
}
updated_metrics.append({k: v for k, v in new_metric.items() if v is not None})
if changes_made:
payload_for_update = {
"database_id": dataset_data.get("database", {}).get("id"),
@@ -222,4 +235,4 @@ class DatasetMapper:
return
# #endregion run_mapping
# #endregion DatasetMapper
# #endregion DatasetMapperModule
# #endregion DatasetMapperModule