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:
@@ -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
|
||||
Reference in New Issue
Block a user