From 1d4fe6e88d83518183991a87b9157039ee98c07b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=ED=98=95=EC=A7=84?= Date: Thu, 14 Aug 2025 16:14:57 +0900 Subject: [PATCH 01/12] =?UTF-8?q?feat:=20postgresql=20=EC=8A=A4=ED=82=A4?= =?UTF-8?q?=EB=A7=88=20=EB=94=94=ED=85=8C=EC=9D=BC=20=EC=A1=B0=ED=9A=8C=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/repository/user_db_repository.py | 323 ++++++++++++++++++--------- app/services/user_db_service.py | 49 +++- 2 files changed, 260 insertions(+), 112 deletions(-) diff --git a/app/repository/user_db_repository.py b/app/repository/user_db_repository.py index b437572..9fa66c5 100644 --- a/app/repository/user_db_repository.py +++ b/app/repository/user_db_repository.py @@ -3,6 +3,7 @@ import oracledb +from app.core.enum.db_driver import DBTypesEnum from app.core.exceptions import APIException from app.core.status import CommonCode from app.core.utils import get_db_path @@ -210,49 +211,9 @@ def find_columns( cursor = connection.cursor() if db_type == "sqlite": - # SQLite는 PRAGMA를 직접 실행 - pragma_sql = f"PRAGMA table_info('{table_name}')" - cursor.execute(pragma_sql) - columns_raw = cursor.fetchall() - columns = [ - ColumnInfo( - name=c[1], - type=c[2], - nullable=(c[3] == 0), # notnull == 0 means nullable - default=c[4], - comment=None, - is_pk=(c[5] == 1), - ) - for c in columns_raw - ] + columns = self._find_columns_for_sqlite(cursor, table_name) else: - if "%s" in column_query or "?" in column_query: - cursor.execute(column_query, (schema_name, table_name)) - elif ":owner" in column_query and ":table" in column_query: - owner_bind = schema_name.upper() if schema_name else schema_name - table_bind = table_name.upper() if table_name else table_name - try: - cursor.execute(column_query, {"owner": owner_bind, "table": table_bind}) - except Exception: - try: - pos_query = column_query.replace(":owner", ":1").replace(":table", ":2") - cursor.execute(pos_query, [owner_bind, table_bind]) - except Exception as e: - raise APIException(CommonCode.FAIL) from e - else: - cursor.execute(column_query) - - columns = [ - ColumnInfo( - name=c[0], - type=c[1], - nullable=(c[2] in ["YES", "Y", True]), - default=c[3], - comment=c[4] if len(c) > 4 else None, - is_pk=(c[5] in ["PRI", True] if len(c) > 5 else False), - ) - for c in cursor.fetchall() - ] + columns = self._find_columns_for_general(cursor, column_query, schema_name, table_name) return ColumnListResult(is_successful=True, code=CommonCode.SUCCESS_FIND_COLUMNS, columns=columns) except Exception: @@ -261,94 +222,252 @@ def find_columns( if connection: connection.close() + def _find_columns_for_sqlite(self, cursor: Any, table_name: str) -> list[ColumnInfo]: + pragma_sql = f"PRAGMA table_info('{table_name}')" + cursor.execute(pragma_sql) + columns_raw = cursor.fetchall() + return [ + ColumnInfo( + name=c[1], + type=c[2], + nullable=(c[3] == 0), + default=c[4], + comment=None, + is_pk=(c[5] == 1), + ) + for c in columns_raw + ] + + def _find_columns_for_general( + self, cursor: Any, column_query: str, schema_name: str, table_name: str + ) -> list[ColumnInfo]: + if "%s" in column_query or "?" in column_query: + cursor.execute(column_query, (schema_name, table_name)) + elif ":owner" in column_query and ":table" in column_query: + owner_bind = schema_name.upper() if schema_name else schema_name + table_bind = table_name.upper() if table_name else table_name + try: + cursor.execute(column_query, {"owner": owner_bind, "table": table_bind}) + except Exception: + try: + pos_query = column_query.replace(":owner", ":1").replace(":table", ":2") + cursor.execute(pos_query, [owner_bind, table_bind]) + except Exception as e: + raise APIException(CommonCode.FAIL) from e + else: + cursor.execute(column_query) + + columns = [] + for c in cursor.fetchall(): + data_type = c[1] + if c[6] is not None: + data_type = f"{data_type}({c[6]})" + elif c[7] is not None and c[8] is not None: + data_type = f"{data_type}({c[7]}, {c[8]})" + + columns.append( + ColumnInfo( + name=c[0], + type=data_type, + nullable=(c[2] in ["YES", "Y", True]), + default=c[3], + comment=c[4] if len(c) > 4 else None, + is_pk=(c[5] in ["PRI", True] if len(c) > 5 else False), + ) + ) + return columns + def find_constraints( - self, driver_module: Any, db_type: str, table_name: str, **kwargs: Any + self, driver_module: Any, db_type: str, schema_name: str, table_name: str, **kwargs: Any ) -> list[ConstraintInfo]: """ 테이블의 제약 조건 정보를 조회합니다. - - 현재는 SQLite만 지원합니다. + - 현재는 SQLite, PostgreSQL만 지원합니다. - 실패 시 DB 드라이버의 예외를 직접 발생시킵니다. """ connection = None try: connection = self._connect(driver_module, **kwargs) cursor = connection.cursor() - constraints = [] - if db_type == "sqlite": - # Foreign Key 제약 조건 조회 - fk_list_sql = f"PRAGMA foreign_key_list('{table_name}')" - cursor.execute(fk_list_sql) - fks = cursor.fetchall() - - # Foreign Key 정보를 그룹화 - fk_groups = {} - for fk in fks: - fk_id = fk[0] - if fk_id not in fk_groups: - fk_groups[fk_id] = {"referenced_table": fk[2], "columns": [], "referenced_columns": []} - fk_groups[fk_id]["columns"].append(fk[3]) - fk_groups[fk_id]["referenced_columns"].append(fk[4]) - - for _, group in fk_groups.items(): - constraints.append( - ConstraintInfo( - name=f"fk_{table_name}_{'_'.join(group['columns'])}", - type="FOREIGN KEY", - columns=group["columns"], - referenced_table=group["referenced_table"], - referenced_columns=group["referenced_columns"], - ) - ) - - # 다른 DB 타입에 대한 제약 조건 조회 로직 추가 가능 - # elif db_type == "postgresql": ... - - return constraints + if db_type == DBTypesEnum.sqlite.name: + return self._find_constraints_for_sqlite(cursor, table_name) + elif db_type == DBTypesEnum.postgresql.name: + return self._find_constraints_for_postgresql(cursor, schema_name, table_name) + # elif db_type == ...: + return [] finally: if connection: connection.close() - def find_indexes(self, driver_module: Any, db_type: str, table_name: str, **kwargs: Any) -> list[IndexInfo]: + def _find_constraints_for_sqlite(self, cursor: Any, table_name: str) -> list[ConstraintInfo]: + constraints = [] + fk_list_sql = f"PRAGMA foreign_key_list('{table_name}')" + cursor.execute(fk_list_sql) + fks = cursor.fetchall() + + # Foreign Key 정보를 그룹화 + fk_groups = {} + for fk in fks: + fk_id = fk[0] + if fk_id not in fk_groups: + fk_groups[fk_id] = {"referenced_table": fk[2], "columns": [], "referenced_columns": []} + fk_groups[fk_id]["columns"].append(fk[3]) + fk_groups[fk_id]["referenced_columns"].append(fk[4]) + + for _, group in fk_groups.items(): + constraints.append( + ConstraintInfo( + name=f"fk_{table_name}_{'_'.join(group['columns'])}", + type="FOREIGN KEY", + columns=group["columns"], + referenced_table=group["referenced_table"], + referenced_columns=group["referenced_columns"], + ) + ) + return constraints + + def _find_constraints_for_postgresql(self, cursor: Any, schema_name: str, table_name: str) -> list[ConstraintInfo]: + sql = """ + SELECT + tc.constraint_name, + tc.constraint_type, + kcu.column_name, + rc.update_rule, + rc.delete_rule, + ccu.table_name AS foreign_table_name, + ccu.column_name AS foreign_column_name, + chk.check_clause + FROM + information_schema.table_constraints tc + LEFT JOIN information_schema.key_column_usage kcu + ON tc.constraint_name = kcu.constraint_name AND tc.table_schema = kcu.table_schema AND tc.table_name = kcu.table_name + LEFT JOIN information_schema.referential_constraints rc + ON tc.constraint_name = rc.constraint_name AND tc.table_schema = rc.constraint_schema + LEFT JOIN information_schema.constraint_column_usage ccu + ON rc.unique_constraint_name = ccu.constraint_name AND rc.unique_constraint_schema = ccu.table_schema + LEFT JOIN information_schema.check_constraints chk + ON tc.constraint_name = chk.constraint_name AND tc.table_schema = chk.constraint_schema + WHERE + tc.table_schema = %s AND tc.table_name = %s; + """ + cursor.execute(sql, (schema_name, table_name)) + raw_constraints = cursor.fetchall() + + constraint_map = {} + for row in raw_constraints: + (name, const_type, column, _, _, ref_table, ref_column, check_expr) = row + if name not in constraint_map: + constraint_map[name] = { + "type": const_type, + "columns": [], + "referenced_table": ref_table, + "referenced_columns": [], + "check_expression": check_expr, + } + if column and column not in constraint_map[name]["columns"]: + constraint_map[name]["columns"].append(column) + if ref_column and ref_column not in constraint_map[name]["referenced_columns"]: + constraint_map[name]["referenced_columns"].append(ref_column) + + return [ + ConstraintInfo( + name=name, + type=data["type"], + columns=data["columns"], + referenced_table=data["referenced_table"], + referenced_columns=data["referenced_columns"] if data["referenced_columns"] else None, + check_expression=data["check_expression"], + ) + for name, data in constraint_map.items() + ] + + def find_indexes( + self, driver_module: Any, db_type: str, schema_name: str, table_name: str, **kwargs: Any + ) -> list[IndexInfo]: """ 테이블의 인덱스 정보를 조회합니다. - - 현재는 SQLite만 지원합니다. - 실패 시 DB 드라이버의 예외를 직접 발생시킵니다. """ connection = None try: connection = self._connect(driver_module, **kwargs) cursor = connection.cursor() - indexes = [] - - if db_type == "sqlite": - index_list_sql = f"PRAGMA index_list('{table_name}')" - cursor.execute(index_list_sql) - raw_indexes = cursor.fetchall() - - for idx in raw_indexes: - index_name = idx[1] - is_unique = idx[2] == 1 - # "sqlite_autoindex_"로 시작하는 인덱스는 PK에 의해 자동 생성된 것이므로 제외 - if index_name.startswith("sqlite_autoindex_"): - continue - - index_info_sql = f"PRAGMA index_info('{index_name}')" - cursor.execute(index_info_sql) - index_columns = [row[2] for row in cursor.fetchall()] - - if index_columns: - indexes.append(IndexInfo(name=index_name, columns=index_columns, is_unique=is_unique)) - - # 다른 DB 타입에 대한 인덱스 조회 로직 추가 가능 - # elif db_type == "postgresql": ... - - return indexes + if db_type == DBTypesEnum.sqlite.name: + return self._find_indexes_for_sqlite(cursor, table_name) + elif db_type == DBTypesEnum.postgresql.name: + return self._find_indexes_for_postgresql(cursor, schema_name, table_name) + # elif db_type == ...: + return [] finally: if connection: connection.close() + def _find_indexes_for_sqlite(self, cursor: Any, table_name: str) -> list[IndexInfo]: + indexes = [] + index_list_sql = f"PRAGMA index_list('{table_name}')" + cursor.execute(index_list_sql) + raw_indexes = cursor.fetchall() + + for idx in raw_indexes: + index_name = idx[1] + is_unique = idx[2] == 1 + + # "sqlite_autoindex_"로 시작하는 인덱스는 PK에 의해 자동 생성된 것이므로 제외 + if index_name.startswith("sqlite_autoindex_"): + continue + + index_info_sql = f"PRAGMA index_info('{index_name}')" + cursor.execute(index_info_sql) + index_columns = [row[2] for row in cursor.fetchall()] + + if index_columns: + indexes.append(IndexInfo(name=index_name, columns=index_columns, is_unique=is_unique)) + return indexes + + def _find_indexes_for_postgresql(self, cursor: Any, schema_name: str, table_name: str) -> list[IndexInfo]: + sql = """ + SELECT + i.relname as index_name, + a.attname as column_name, + ix.indisunique as is_unique, + ix.indisprimary as is_primary + FROM + pg_class t, + pg_class i, + pg_index ix, + pg_attribute a, + pg_namespace n + WHERE + t.oid = ix.indrelid + and i.oid = ix.indexrelid + and a.attrelid = t.oid + and a.attnum = ANY(ix.indkey) + and t.relkind = 'r' + and n.oid = t.relnamespace + and n.nspname = %s + and t.relname = %s + ORDER BY + i.relname, a.attnum; + """ + cursor.execute(sql, (schema_name, table_name)) + raw_indexes = cursor.fetchall() + + index_map = {} + for row in raw_indexes: + index_name, column_name, is_unique, is_primary = row + if is_primary: # Exclude indexes created for PRIMARY KEY constraints + continue + if index_name not in index_map: + index_map[index_name] = {"columns": [], "is_unique": is_unique} + index_map[index_name]["columns"].append(column_name) + + return [ + IndexInfo(name=name, columns=data["columns"], is_unique=data["is_unique"]) + for name, data in index_map.items() + ] + # ───────────────────────────── # DB 연결 메서드 # ───────────────────────────── diff --git a/app/services/user_db_service.py b/app/services/user_db_service.py index 3afe6fd..eff501a 100644 --- a/app/services/user_db_service.py +++ b/app/services/user_db_service.py @@ -192,10 +192,12 @@ def get_full_schema_info( try: constraints = repository.find_constraints( - driver_module, db_info.type, table_name, **connect_kwargs + driver_module, db_info.type, schema_name, table_name, **connect_kwargs ) - indexes = repository.find_indexes(driver_module, db_info.type, table_name, **connect_kwargs) - except sqlite3.Error as e: + indexes = repository.find_indexes( + driver_module, db_info.type, schema_name, table_name, **connect_kwargs + ) + except (sqlite3.Error, self._get_driver_module(db_info.type).Error) as e: # 레포지토리에서 발생한 DB 예외를 서비스에서 처리 raise APIException(CommonCode.FAIL_FIND_CONSTRAINTS_OR_INDEXES) from e @@ -268,7 +270,7 @@ def _get_schema_query(self, db_type: str) -> str | None: if db_type == "postgresql": return """ SELECT schema_name FROM information_schema.schemata - WHERE schema_name NOT IN ('pg_catalog', 'information_schema') + WHERE schema_name NOT IN ('pg_catalog', 'information_schema', 'pg_toast') """ elif db_type in ["mysql", "mariadb"]: return "SELECT schema_name FROM information_schema.schemata" @@ -288,7 +290,7 @@ def _get_table_query(self, db_type: str, for_all_schemas: bool = False) -> str | """ else: return """ - SELECT table_name, table_schema FROM information_schema.tables + SELECT table_name FROM information_schema.tables WHERE table_type = 'BASE TABLE' AND table_schema = %s """ elif db_type in ["mysql", "mariadb"]: @@ -312,11 +314,38 @@ def _get_column_query(self, db_type: str) -> str | None: db_type = db_type.lower() if db_type == "postgresql": return """ - SELECT column_name, data_type, is_nullable, column_default, table_name, table_schema - FROM information_schema.columns - WHERE table_schema NOT IN ('pg_catalog', 'information_schema') - AND table_schema = %s - AND table_name = %s + SELECT + c.column_name, + c.data_type, + c.is_nullable, + c.column_default, + pgd.description AS comment, + ( + SELECT TRUE + FROM information_schema.table_constraints tc + JOIN information_schema.key_column_usage kcu + ON tc.constraint_name = kcu.constraint_name + AND tc.table_schema = kcu.table_schema + WHERE tc.constraint_type = 'PRIMARY KEY' + AND tc.table_schema = c.table_schema + AND tc.table_name = c.table_name + AND kcu.column_name = c.column_name + ) AS is_pk, + c.character_maximum_length, + c.numeric_precision, + c.numeric_scale + FROM + information_schema.columns c + LEFT JOIN + pg_catalog.pg_stat_all_tables st + ON c.table_schema = st.schemaname AND c.table_name = st.relname + LEFT JOIN + pg_catalog.pg_description pgd + ON pgd.objoid = st.relid AND pgd.objsubid = c.ordinal_position + WHERE + c.table_schema = %s AND c.table_name = %s + ORDER BY + c.ordinal_position; """ elif db_type in ["mysql", "mariadb"]: return """ From 86642b285b0aea3894d909e3c32cfa9d434f7ade Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=ED=98=95=EC=A7=84?= Date: Thu, 14 Aug 2025 16:36:16 +0900 Subject: [PATCH 02/12] =?UTF-8?q?refactor:=20=EC=9B=90=EB=B3=B8=20?= =?UTF-8?q?=EC=8A=A4=ED=82=A4=EB=A7=88=EC=99=80=20AI=20=EC=9D=91=EB=8B=B5?= =?UTF-8?q?=EC=9D=84=20=EB=AA=A8=EB=91=90=20=ED=99=9C=EC=9A=A9=ED=95=98?= =?UTF-8?q?=EB=8F=84=EB=A1=9D=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/services/annotation_service.py | 234 ++++++++++++++++++++--------- 1 file changed, 162 insertions(+), 72 deletions(-) diff --git a/app/services/annotation_service.py b/app/services/annotation_service.py index b217586..651b564 100644 --- a/app/services/annotation_service.py +++ b/app/services/annotation_service.py @@ -65,7 +65,9 @@ async def create_annotation(self, request: AnnotationCreateRequest) -> FullAnnot conn = sqlite3.connect(str(db_path), timeout=10) conn.execute("BEGIN") - db_models = self._transform_ai_response_to_db_models(ai_response, db_profile, request.db_profile_id) + db_models = self._transform_ai_response_to_db_models( + ai_response, db_profile, request.db_profile_id, full_schema_info + ) self.repository.create_full_annotation(db_conn=conn, **db_models) conn.commit() @@ -82,11 +84,18 @@ async def create_annotation(self, request: AnnotationCreateRequest) -> FullAnnot return self.get_full_annotation(annotation_id) def _transform_ai_response_to_db_models( - self, ai_response: dict[str, Any], db_profile: AllDBProfileInfo, db_profile_id: str + self, + ai_response: dict[str, Any], + db_profile: AllDBProfileInfo, + db_profile_id: str, + full_schema_info: list[UserDBTableInfo], ) -> dict[str, Any]: now = datetime.now() annotation_id = generate_prefixed_uuid(DBSaveIdEnum.database_annotation.value) + # 원본 스키마 정보를 쉽게 조회할 수 있도록 룩업 테이블 생성 + schema_lookup: dict[str, UserDBTableInfo] = {table.name: table for table in full_schema_info} + db_anno = DatabaseAnnotationInDB( id=annotation_id, db_profile_id=db_profile_id, @@ -96,7 +105,14 @@ def _transform_ai_response_to_db_models( updated_at=now, ) - table_annos, col_annos, constraint_annos, constraint_col_annos, index_annos, index_col_annos = ( + ( + all_table_annos, + all_col_annos, + all_constraint_annos, + all_constraint_col_annos, + all_index_annos, + all_index_col_annos, + ) = ( [], [], [], @@ -106,90 +122,164 @@ def _transform_ai_response_to_db_models( ) for tbl_data in ai_response.get("tables", []): - table_id = generate_prefixed_uuid(DBSaveIdEnum.table_annotation.value) - table_annos.append( - TableAnnotationInDB( - id=table_id, - database_annotation_id=annotation_id, - table_name=tbl_data["table_name"], - description=tbl_data.get("annotation"), + original_table = schema_lookup.get(tbl_data["table_name"]) + if not original_table: + continue + + ( + table_anno, + col_annos, + constraint_annos, + constraint_col_annos, + index_annos, + index_col_annos, + ) = self._create_annotations_for_table(tbl_data, original_table, annotation_id, now) + + all_table_annos.append(table_anno) + all_col_annos.extend(col_annos) + all_constraint_annos.extend(constraint_annos) + all_constraint_col_annos.extend(constraint_col_annos) + all_index_annos.extend(index_annos) + all_index_col_annos.extend(index_col_annos) + + return { + "db_annotation": db_anno, + "table_annotations": all_table_annos, + "column_annotations": all_col_annos, + "constraint_annotations": all_constraint_annos, + "constraint_column_annotations": all_constraint_col_annos, + "index_annotations": all_index_annos, + "index_column_annotations": all_index_col_annos, + } + + def _create_annotations_for_table( + self, + tbl_data: dict[str, Any], + original_table: UserDBTableInfo, + database_annotation_id: str, + now: datetime, + ) -> tuple: + table_id = generate_prefixed_uuid(DBSaveIdEnum.table_annotation.value) + table_anno = TableAnnotationInDB( + id=table_id, + database_annotation_id=database_annotation_id, + table_name=original_table.name, + description=tbl_data.get("annotation"), + created_at=now, + updated_at=now, + ) + + col_map = { + col.name: generate_prefixed_uuid(DBSaveIdEnum.column_annotation.value) for col in original_table.columns + } + + col_annos = self._process_columns(tbl_data, original_table, table_id, col_map, now) + constraint_annos, constraint_col_annos = self._process_constraints( + tbl_data, original_table, table_id, col_map, now + ) + index_annos, index_col_annos = self._process_indexes(tbl_data, original_table, table_id, col_map, now) + + return table_anno, col_annos, constraint_annos, constraint_col_annos, index_annos, index_col_annos + + def _process_columns( + self, tbl_data: dict, original_table: UserDBTableInfo, table_id: str, col_map: dict, now: datetime + ) -> list[ColumnAnnotationInDB]: + col_annos = [] + for col_data in tbl_data.get("columns", []): + original_column = next((c for c in original_table.columns if c.name == col_data["column_name"]), None) + if not original_column: + continue + col_annos.append( + ColumnAnnotationInDB( + id=col_map[original_column.name], + table_annotation_id=table_id, + column_name=original_column.name, + data_type=original_column.type, + is_nullable=1 if original_column.nullable else 0, + default_value=original_column.default, + description=col_data.get("annotation"), + # TODO: check_expression, ordinal_position은 현재 original_column에 없음 created_at=now, updated_at=now, ) ) + return col_annos - col_map = { - col["column_name"]: generate_prefixed_uuid(DBSaveIdEnum.column_annotation.value) - for col in tbl_data.get("columns", []) - } - - for col_data in tbl_data.get("columns", []): - col_annos.append( - ColumnAnnotationInDB( - id=col_map[col_data["column_name"]], - table_annotation_id=table_id, - column_name=col_data["column_name"], - description=col_data.get("annotation"), - created_at=now, - updated_at=now, - ) + def _process_constraints( + self, tbl_data: dict, original_table: UserDBTableInfo, table_id: str, col_map: dict, now: datetime + ) -> tuple[list[TableConstraintInDB], list[ConstraintColumnInDB]]: + constraint_annos, constraint_col_annos = [], [] + for const_data in tbl_data.get("constraints", []): + original_constraint = next((c for c in original_table.constraints if c.name == const_data["name"]), None) + if not original_constraint: + continue + const_id = generate_prefixed_uuid(DBSaveIdEnum.table_constraint.value) + constraint_annos.append( + TableConstraintInDB( + id=const_id, + table_annotation_id=table_id, + name=original_constraint.name, + constraint_type=ConstraintTypeEnum(original_constraint.type), + ref_table=original_constraint.referenced_table, + # TODO: on_update/on_delete/expression 등 추가 정보 필요 + created_at=now, + updated_at=now, ) - - for const_data in tbl_data.get("constraints", []): - const_id = generate_prefixed_uuid(DBSaveIdEnum.table_constraint.value) - constraint_annos.append( - TableConstraintInDB( - id=const_id, - table_annotation_id=table_id, - name=const_data["name"], - constraint_type=ConstraintTypeEnum(const_data["type"]), + ) + for i, col_name in enumerate(original_constraint.columns): + if col_name not in col_map: + continue + constraint_col_annos.append( + ConstraintColumnInDB( + id=generate_prefixed_uuid(DBSaveIdEnum.constraint_column.value), + constraint_id=const_id, + column_annotation_id=col_map[col_name], + position=i + 1, + referenced_column_name=( + original_constraint.referenced_columns[i] + if original_constraint.referenced_columns + and i < len(original_constraint.referenced_columns) + else None + ), created_at=now, updated_at=now, ) ) - for col_name in const_data.get("columns", []): - constraint_col_annos.append( - ConstraintColumnInDB( - id=generate_prefixed_uuid(DBSaveIdEnum.constraint_column.value), - constraint_id=const_id, - column_annotation_id=col_map[col_name], - created_at=now, - updated_at=now, - ) - ) + return constraint_annos, constraint_col_annos - for idx_data in tbl_data.get("indexes", []): - idx_id = generate_prefixed_uuid(DBSaveIdEnum.index_annotation.value) - index_annos.append( - IndexAnnotationInDB( - id=idx_id, - table_annotation_id=table_id, - name=idx_data["name"], - is_unique=1 if idx_data.get("is_unique") else 0, + def _process_indexes( + self, tbl_data: dict, original_table: UserDBTableInfo, table_id: str, col_map: dict, now: datetime + ) -> tuple[list[IndexAnnotationInDB], list[IndexColumnInDB]]: + index_annos, index_col_annos = [], [] + for idx_data in tbl_data.get("indexes", []): + original_index = next((i for i in original_table.indexes if i.name == idx_data["name"]), None) + if not original_index: + continue + idx_id = generate_prefixed_uuid(DBSaveIdEnum.index_annotation.value) + index_annos.append( + IndexAnnotationInDB( + id=idx_id, + table_annotation_id=table_id, + name=original_index.name, + is_unique=1 if original_index.is_unique else 0, + created_at=now, + updated_at=now, + ) + ) + for i, col_name in enumerate(original_index.columns): + if col_name not in col_map: + continue + index_col_annos.append( + IndexColumnInDB( + id=generate_prefixed_uuid(DBSaveIdEnum.index_column.value), + index_id=idx_id, + column_annotation_id=col_map[col_name], + position=i + 1, created_at=now, updated_at=now, ) ) - for col_name in idx_data.get("columns", []): - index_col_annos.append( - IndexColumnInDB( - id=generate_prefixed_uuid(DBSaveIdEnum.index_column.value), - index_id=idx_id, - column_annotation_id=col_map[col_name], - created_at=now, - updated_at=now, - ) - ) - - return { - "db_annotation": db_anno, - "table_annotations": table_annos, - "column_annotations": col_annos, - "constraint_annotations": constraint_annos, - "constraint_column_annotations": constraint_col_annos, - "index_annotations": index_annos, - "index_column_annotations": index_col_annos, - } + return index_annos, index_col_annos def get_full_annotation(self, annotation_id: str) -> FullAnnotationResponse: try: From 55ea5f1a3cc20bf5399fe9b61b604aaaa9f1fae3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=ED=98=95=EC=A7=84?= Date: Thu, 14 Aug 2025 16:54:54 +0900 Subject: [PATCH 03/12] =?UTF-8?q?fix:=20DB=EC=97=90=20=EC=A0=80=EC=9E=A5?= =?UTF-8?q?=EB=90=98=EC=A7=80=EB=A7=8C=20=EC=9D=91=EB=8B=B5=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=EB=88=84=EB=9D=BD=EB=90=9C=20=ED=95=84=EB=93=9C=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/repository/annotation_repository.py | 26 +++++++++++++++++------- app/schemas/annotation/response_model.py | 3 +++ app/services/annotation_service.py | 2 +- 3 files changed, 23 insertions(+), 8 deletions(-) diff --git a/app/repository/annotation_repository.py b/app/repository/annotation_repository.py index c32ec61..0e2b3e5 100644 --- a/app/repository/annotation_repository.py +++ b/app/repository/annotation_repository.py @@ -182,18 +182,24 @@ def find_full_annotation_by_id(self, annotation_id: str) -> FullAnnotationRespon # 컬럼 정보 cursor.execute( - "SELECT id, column_name, description FROM column_annotation WHERE table_annotation_id = ?", + "SELECT id, column_name, description, data_type, is_nullable, default_value FROM column_annotation WHERE table_annotation_id = ?", (table_id,), ) - columns = [ColumnAnnotationDetail.model_validate(dict(c)) for c in cursor.fetchall()] + columns = [] + for c in cursor.fetchall(): + c_dict = dict(c) + c_dict["is_nullable"] = ( + bool(c_dict["is_nullable"]) if c_dict.get("is_nullable") is not None else None + ) + columns.append(ColumnAnnotationDetail.model_validate(c_dict)) # 제약조건 정보 cursor.execute( """ SELECT tc.name, tc.constraint_type, ca.column_name FROM table_constraint tc - JOIN constraint_column cc ON tc.id = cc.constraint_id - JOIN column_annotation ca ON cc.column_annotation_id = ca.id + LEFT JOIN constraint_column cc ON tc.id = cc.constraint_id + LEFT JOIN column_annotation ca ON cc.column_annotation_id = ca.id WHERE tc.table_annotation_id = ? """, (table_id,), @@ -201,10 +207,16 @@ def find_full_annotation_by_id(self, annotation_id: str) -> FullAnnotationRespon constraint_map = {} for row in cursor.fetchall(): if row["name"] not in constraint_map: - constraint_map[row["name"]] = {"type": row["constraint_type"], "columns": []} - constraint_map[row["name"]]["columns"].append(row["column_name"]) + constraint_map[row["name"]] = { + "type": row["constraint_type"], + "columns": [], + "description": None, + } + if row["column_name"]: + constraint_map[row["name"]]["columns"].append(row["column_name"]) constraints = [ - ConstraintDetail(name=k, type=v["type"], columns=v["columns"]) for k, v in constraint_map.items() + ConstraintDetail(name=k, type=v["type"], columns=v["columns"], description=v["description"]) + for k, v in constraint_map.items() ] # 인덱스 정보 diff --git a/app/schemas/annotation/response_model.py b/app/schemas/annotation/response_model.py index 5602e0e..5ec4556 100644 --- a/app/schemas/annotation/response_model.py +++ b/app/schemas/annotation/response_model.py @@ -10,6 +10,9 @@ class ColumnAnnotationDetail(BaseModel): id: str column_name: str description: str | None = None + data_type: str | None = None + is_nullable: bool | None = None + default_value: str | None = None class ConstraintDetail(BaseModel): diff --git a/app/services/annotation_service.py b/app/services/annotation_service.py index 651b564..8489c54 100644 --- a/app/services/annotation_service.py +++ b/app/services/annotation_service.py @@ -99,7 +99,7 @@ def _transform_ai_response_to_db_models( db_anno = DatabaseAnnotationInDB( id=annotation_id, db_profile_id=db_profile_id, - database_name=db_profile.name, + database_name=db_profile.name or db_profile.username, description=ai_response.get("database_annotation"), created_at=now, updated_at=now, From ac431f6344bcb196603b1c2bd0d49326655e1982 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=ED=98=95=EC=A7=84?= Date: Thu, 14 Aug 2025 19:14:00 +0900 Subject: [PATCH 04/12] =?UTF-8?q?feat:=20postgre=20=EC=A0=84=EC=9A=A9=20?= =?UTF-8?q?=EC=BB=AC=EB=9F=BC=20=EC=A1=B0=ED=9A=8C=20=EB=A9=94=EC=84=9C?= =?UTF-8?q?=EB=93=9C=20=EC=83=9D=EC=84=B1=20&=20ordinal=20=EC=A0=95?= =?UTF-8?q?=EB=B3=B4=20=ED=8F=AC=ED=95=A8=20=EB=B0=98=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/repository/user_db_repository.py | 53 +++++++++++++++++++++++++++- 1 file changed, 52 insertions(+), 1 deletion(-) diff --git a/app/repository/user_db_repository.py b/app/repository/user_db_repository.py index 9fa66c5..d60c25f 100644 --- a/app/repository/user_db_repository.py +++ b/app/repository/user_db_repository.py @@ -210,8 +210,10 @@ def find_columns( connection = self._connect(driver_module, **kwargs) cursor = connection.cursor() - if db_type == "sqlite": + if db_type == DBTypesEnum.sqlite.name: columns = self._find_columns_for_sqlite(cursor, table_name) + elif db_type == DBTypesEnum.postgresql.name: + columns = self._find_columns_for_postgresql(cursor, schema_name, table_name) else: columns = self._find_columns_for_general(cursor, column_query, schema_name, table_name) @@ -226,6 +228,7 @@ def _find_columns_for_sqlite(self, cursor: Any, table_name: str) -> list[ColumnI pragma_sql = f"PRAGMA table_info('{table_name}')" cursor.execute(pragma_sql) columns_raw = cursor.fetchall() + # SQLite는 pragma에서 순서(cid)를 반환하지만, ordinal_position은 1부터 시작하는 표준이므로 +1 return [ ColumnInfo( name=c[1], @@ -234,6 +237,54 @@ def _find_columns_for_sqlite(self, cursor: Any, table_name: str) -> list[ColumnI default=c[4], comment=None, is_pk=(c[5] == 1), + ordinal_position=c[0] + 1, + ) + for c in columns_raw + ] + + def _find_columns_for_postgresql(self, cursor: Any, schema_name: str, table_name: str) -> list[ColumnInfo]: + sql = """ + SELECT + column_name, + udt_name, + is_nullable, + column_default, + ordinal_position, + (SELECT pg_catalog.col_description(c.oid, a.attnum) + FROM pg_catalog.pg_class c + JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace + WHERE c.relname = a.table_name AND n.nspname = a.table_schema) as comment, + CASE + WHEN ( + SELECT constraint_type + FROM information_schema.table_constraints tc + JOIN information_schema.key_column_usage kcu + ON tc.constraint_name = kcu.constraint_name + WHERE tc.table_schema = a.table_schema + AND tc.table_name = a.table_name + AND kcu.column_name = a.column_name + AND tc.constraint_type = 'PRIMARY KEY' + ) = 'PRIMARY KEY' THEN TRUE + ELSE FALSE + END as is_pk + FROM + information_schema.columns a + WHERE + table_schema = %s AND table_name = %s + ORDER BY + ordinal_position; + """ + cursor.execute(sql, (schema_name, table_name)) + columns_raw = cursor.fetchall() + return [ + ColumnInfo( + name=c[0], + type=c[1], + nullable=(c[2] == "YES"), + default=c[3], + ordinal_position=c[4], + comment=c[5], + is_pk=c[6], ) for c in columns_raw ] From a0e82e6af3ab3a006cb4daa04c99f385662cde05 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=ED=98=95=EC=A7=84?= Date: Thu, 14 Aug 2025 19:17:02 +0900 Subject: [PATCH 05/12] =?UTF-8?q?fix:=20on=5Fupdate=20&=20on=5Fdelete=20?= =?UTF-8?q?=EB=88=84=EB=9D=BD=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/repository/user_db_repository.py | 6 +++++- app/schemas/user_db/result_model.py | 3 +++ app/services/annotation_service.py | 6 ++++-- 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/app/repository/user_db_repository.py b/app/repository/user_db_repository.py index d60c25f..c1eb0a8 100644 --- a/app/repository/user_db_repository.py +++ b/app/repository/user_db_repository.py @@ -407,7 +407,7 @@ def _find_constraints_for_postgresql(self, cursor: Any, schema_name: str, table_ constraint_map = {} for row in raw_constraints: - (name, const_type, column, _, _, ref_table, ref_column, check_expr) = row + (name, const_type, column, on_update, on_delete, ref_table, ref_column, check_expr) = row if name not in constraint_map: constraint_map[name] = { "type": const_type, @@ -415,6 +415,8 @@ def _find_constraints_for_postgresql(self, cursor: Any, schema_name: str, table_ "referenced_table": ref_table, "referenced_columns": [], "check_expression": check_expr, + "on_update": on_update, + "on_delete": on_delete, } if column and column not in constraint_map[name]["columns"]: constraint_map[name]["columns"].append(column) @@ -429,6 +431,8 @@ def _find_constraints_for_postgresql(self, cursor: Any, schema_name: str, table_ referenced_table=data["referenced_table"], referenced_columns=data["referenced_columns"] if data["referenced_columns"] else None, check_expression=data["check_expression"], + on_update=data["on_update"], + on_delete=data["on_delete"], ) for name, data in constraint_map.items() ] diff --git a/app/schemas/user_db/result_model.py b/app/schemas/user_db/result_model.py index 64d9190..d379019 100644 --- a/app/schemas/user_db/result_model.py +++ b/app/schemas/user_db/result_model.py @@ -53,6 +53,7 @@ class ColumnInfo(BaseModel): default: Any | None = Field(None, description="기본값") comment: str | None = Field(None, description="코멘트") is_pk: bool = Field(False, description="기본 키(Primary Key) 여부") + ordinal_position: int | None = Field(None, description="컬럼 순서") class ConstraintInfo(BaseModel): @@ -64,6 +65,8 @@ class ConstraintInfo(BaseModel): # FOREIGN KEY 관련 필드 referenced_table: str | None = Field(None, description="참조하는 테이블 (FK)") referenced_columns: list[str] | None = Field(None, description="참조하는 테이블의 컬럼 (FK)") + on_update: str | None = Field(None, description="UPDATE 시 동작 (FK)") + on_delete: str | None = Field(None, description="DELETE 시 동작 (FK)") # CHECK 관련 필드 check_expression: str | None = Field(None, description="CHECK 제약 조건 표현식") diff --git a/app/services/annotation_service.py b/app/services/annotation_service.py index 8489c54..90c8559 100644 --- a/app/services/annotation_service.py +++ b/app/services/annotation_service.py @@ -198,7 +198,7 @@ def _process_columns( is_nullable=1 if original_column.nullable else 0, default_value=original_column.default, description=col_data.get("annotation"), - # TODO: check_expression, ordinal_position은 현재 original_column에 없음 + ordinal_position=original_column.ordinal_position, created_at=now, updated_at=now, ) @@ -221,7 +221,9 @@ def _process_constraints( name=original_constraint.name, constraint_type=ConstraintTypeEnum(original_constraint.type), ref_table=original_constraint.referenced_table, - # TODO: on_update/on_delete/expression 등 추가 정보 필요 + expression=original_constraint.check_expression, + on_update_action=original_constraint.on_update, + on_delete_action=original_constraint.on_delete, created_at=now, updated_at=now, ) From 675bd1a315149e57a6ae636795eab40d4ad5501d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=ED=98=95=EC=A7=84?= Date: Thu, 14 Aug 2025 19:29:50 +0900 Subject: [PATCH 06/12] =?UTF-8?q?fix:=20postgres=20=EC=A1=B0=ED=9A=8C?= =?UTF-8?q?=EC=8B=9C=20columns=EA=B0=80=20=EB=B9=88=20=EB=A6=AC=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=EB=A1=9C=20=EB=B0=98=ED=99=98=EB=90=98=EB=8D=98=20?= =?UTF-8?q?=EB=AC=B8=EC=A0=9C=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/repository/user_db_repository.py | 51 ++++++++++++++-------------- 1 file changed, 25 insertions(+), 26 deletions(-) diff --git a/app/repository/user_db_repository.py b/app/repository/user_db_repository.py index c1eb0a8..fe748a0 100644 --- a/app/repository/user_db_repository.py +++ b/app/repository/user_db_repository.py @@ -245,36 +245,35 @@ def _find_columns_for_sqlite(self, cursor: Any, table_name: str) -> list[ColumnI def _find_columns_for_postgresql(self, cursor: Any, schema_name: str, table_name: str) -> list[ColumnInfo]: sql = """ SELECT - column_name, - udt_name, - is_nullable, - column_default, - ordinal_position, - (SELECT pg_catalog.col_description(c.oid, a.attnum) - FROM pg_catalog.pg_class c - JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace - WHERE c.relname = a.table_name AND n.nspname = a.table_schema) as comment, - CASE - WHEN ( - SELECT constraint_type - FROM information_schema.table_constraints tc - JOIN information_schema.key_column_usage kcu - ON tc.constraint_name = kcu.constraint_name - WHERE tc.table_schema = a.table_schema - AND tc.table_name = a.table_name - AND kcu.column_name = a.column_name - AND tc.constraint_type = 'PRIMARY KEY' - ) = 'PRIMARY KEY' THEN TRUE - ELSE FALSE - END as is_pk + c.column_name, + c.udt_name, + c.is_nullable, + c.column_default, + c.ordinal_position, + (SELECT pg_catalog.col_description(cls.oid, c.dtd_identifier::int) + FROM pg_catalog.pg_class cls + JOIN pg_catalog.pg_namespace n ON n.oid = cls.relnamespace + WHERE cls.relname = c.table_name AND n.nspname = c.table_schema) as comment, + CASE WHEN kcu.column_name IS NOT NULL THEN TRUE ELSE FALSE END as is_pk FROM - information_schema.columns a + information_schema.columns c + LEFT JOIN information_schema.key_column_usage kcu + ON c.table_schema = kcu.table_schema + AND c.table_name = kcu.table_name + AND c.column_name = kcu.column_name + AND kcu.constraint_name IN ( + SELECT constraint_name + FROM information_schema.table_constraints + WHERE table_schema = %s + AND table_name = %s + AND constraint_type = 'PRIMARY KEY' + ) WHERE - table_schema = %s AND table_name = %s + c.table_schema = %s AND c.table_name = %s ORDER BY - ordinal_position; + c.ordinal_position; """ - cursor.execute(sql, (schema_name, table_name)) + cursor.execute(sql, (schema_name, table_name, schema_name, table_name)) columns_raw = cursor.fetchall() return [ ColumnInfo( From 8b767133ea6f6e34abe333e63be36833e4935416 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=ED=98=95=EC=A7=84?= Date: Thu, 14 Aug 2025 19:35:20 +0900 Subject: [PATCH 07/12] =?UTF-8?q?feat:=20postgresql=EC=9D=B4=20=EB=82=B4?= =?UTF-8?q?=EB=B6=80=EC=A0=81=EC=9C=BC=EB=A1=9C=20NOT=20NULL=EC=9D=84=20CH?= =?UTF-8?q?ECK=EB=A1=9C=20=EA=B4=80=EB=A6=AC=ED=95=98=EC=97=AC=20=EC=9D=B4?= =?UTF-8?q?=EB=A5=BC=20=ED=95=84=ED=84=B0=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/repository/user_db_repository.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/app/repository/user_db_repository.py b/app/repository/user_db_repository.py index fe748a0..0ddf8b3 100644 --- a/app/repository/user_db_repository.py +++ b/app/repository/user_db_repository.py @@ -406,7 +406,13 @@ def _find_constraints_for_postgresql(self, cursor: Any, schema_name: str, table_ constraint_map = {} for row in raw_constraints: - (name, const_type, column, on_update, on_delete, ref_table, ref_column, check_expr) = row + # Filter out 'NOT NULL' constraints which are handled by `is_nullable` in column info + const_type = row[1] + check_clause = row[7] + if const_type == "CHECK" and check_clause and "IS NOT NULL" in check_clause.upper(): + continue + + (name, _, column, on_update, on_delete, ref_table, ref_column, check_expr) = row if name not in constraint_map: constraint_map[name] = { "type": const_type, From 356109c46527baef6c03a2c5bfe3a560217af1a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=ED=98=95=EC=A7=84?= Date: Thu, 14 Aug 2025 19:45:54 +0900 Subject: [PATCH 08/12] =?UTF-8?q?feat:=20constraints=EC=97=90=20descriptio?= =?UTF-8?q?n=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/db/init_db.py | 1 + app/repository/annotation_repository.py | 9 +++++---- app/schemas/annotation/db_model.py | 1 + app/services/annotation_service.py | 1 + 4 files changed, 8 insertions(+), 4 deletions(-) diff --git a/app/db/init_db.py b/app/db/init_db.py index 2a83093..9148262 100644 --- a/app/db/init_db.py +++ b/app/db/init_db.py @@ -303,6 +303,7 @@ def initialize_database(): "table_annotation_id": "VARCHAR(64) NOT NULL", "constraint_type": "VARCHAR(16) NOT NULL", "name": "VARCHAR(255)", + "description": "TEXT", "expression": "TEXT", "ref_table": "VARCHAR(255)", "on_update_action": "VARCHAR(16)", diff --git a/app/repository/annotation_repository.py b/app/repository/annotation_repository.py index 0e2b3e5..3afae67 100644 --- a/app/repository/annotation_repository.py +++ b/app/repository/annotation_repository.py @@ -96,6 +96,7 @@ def create_full_annotation( c.table_annotation_id, c.constraint_type, c.name, + c.description, c.expression, c.ref_table, c.on_update_action, @@ -107,8 +108,8 @@ def create_full_annotation( ] cursor.executemany( """ - INSERT INTO table_constraint (id, table_annotation_id, constraint_type, name, expression, ref_table, on_update_action, on_delete_action, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + INSERT INTO table_constraint (id, table_annotation_id, constraint_type, name, description, expression, ref_table, on_update_action, on_delete_action, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, constraint_data, ) @@ -196,7 +197,7 @@ def find_full_annotation_by_id(self, annotation_id: str) -> FullAnnotationRespon # 제약조건 정보 cursor.execute( """ - SELECT tc.name, tc.constraint_type, ca.column_name + SELECT tc.name, tc.constraint_type, tc.description, ca.column_name FROM table_constraint tc LEFT JOIN constraint_column cc ON tc.id = cc.constraint_id LEFT JOIN column_annotation ca ON cc.column_annotation_id = ca.id @@ -210,7 +211,7 @@ def find_full_annotation_by_id(self, annotation_id: str) -> FullAnnotationRespon constraint_map[row["name"]] = { "type": row["constraint_type"], "columns": [], - "description": None, + "description": row["description"], } if row["column_name"]: constraint_map[row["name"]]["columns"].append(row["column_name"]) diff --git a/app/schemas/annotation/db_model.py b/app/schemas/annotation/db_model.py index 667765b..8a73aa9 100644 --- a/app/schemas/annotation/db_model.py +++ b/app/schemas/annotation/db_model.py @@ -39,6 +39,7 @@ class TableConstraintInDB(AnnotationBase): table_annotation_id: str constraint_type: ConstraintTypeEnum name: str | None = None + description: str | None = None expression: str | None = None ref_table: str | None = None on_update_action: str | None = None diff --git a/app/services/annotation_service.py b/app/services/annotation_service.py index 90c8559..63f2783 100644 --- a/app/services/annotation_service.py +++ b/app/services/annotation_service.py @@ -220,6 +220,7 @@ def _process_constraints( table_annotation_id=table_id, name=original_constraint.name, constraint_type=ConstraintTypeEnum(original_constraint.type), + description=const_data.get("annotation"), ref_table=original_constraint.referenced_table, expression=original_constraint.check_expression, on_update_action=original_constraint.on_update, From d122a2ee059d102da636d523e4e6ee0bd3ca9e25 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=ED=98=95=EC=A7=84?= Date: Thu, 14 Aug 2025 20:52:17 +0900 Subject: [PATCH 09/12] =?UTF-8?q?docs:=20=EC=A3=BC=EC=84=9D=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/repository/annotation_repository.py | 5 +++++ app/services/annotation_service.py | 28 +++++++++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/app/repository/annotation_repository.py b/app/repository/annotation_repository.py index 3afae67..0a74be9 100644 --- a/app/repository/annotation_repository.py +++ b/app/repository/annotation_repository.py @@ -20,6 +20,11 @@ class AnnotationRepository: + """ + 어노테이션 데이터에 대한 데이터베이스 CRUD 작업을 처리합니다. + 모든 메서드는 내부적으로 `sqlite3`를 사용하여 로컬 DB와 상호작용합니다. + """ + def create_full_annotation( self, db_conn: sqlite3.Connection, diff --git a/app/services/annotation_service.py b/app/services/annotation_service.py index 63f2783..c7a7ce7 100644 --- a/app/services/annotation_service.py +++ b/app/services/annotation_service.py @@ -36,6 +36,13 @@ class AnnotationService: def __init__( self, repository: AnnotationRepository = annotation_repository, user_db_serv: UserDbService = user_db_service ): + """ + AnnotationService를 초기화합니다. + + Args: + repository (AnnotationRepository): 어노테이션 레포지토리 의존성 주입. + user_db_serv (UserDbService): 사용자 DB 서비스 의존성 주입. + """ self.repository = repository self.user_db_service = user_db_serv @@ -90,6 +97,9 @@ def _transform_ai_response_to_db_models( db_profile_id: str, full_schema_info: list[UserDBTableInfo], ) -> dict[str, Any]: + """ + AI 서버의 응답을 받아서 DB에 저장할 수 있는 모델 딕셔너리로 변환합니다. + """ now = datetime.now() annotation_id = generate_prefixed_uuid(DBSaveIdEnum.database_annotation.value) @@ -159,6 +169,9 @@ def _create_annotations_for_table( database_annotation_id: str, now: datetime, ) -> tuple: + """ + 단일 테이블에 대한 모든 하위 어노테이션(컬럼, 제약조건, 인덱스)을 생성합니다. + """ table_id = generate_prefixed_uuid(DBSaveIdEnum.table_annotation.value) table_anno = TableAnnotationInDB( id=table_id, @@ -184,6 +197,9 @@ def _create_annotations_for_table( def _process_columns( self, tbl_data: dict, original_table: UserDBTableInfo, table_id: str, col_map: dict, now: datetime ) -> list[ColumnAnnotationInDB]: + """ + 테이블의 컬럼 어노테이션 모델 리스트를 생성합니다. + """ col_annos = [] for col_data in tbl_data.get("columns", []): original_column = next((c for c in original_table.columns if c.name == col_data["column_name"]), None) @@ -208,6 +224,9 @@ def _process_columns( def _process_constraints( self, tbl_data: dict, original_table: UserDBTableInfo, table_id: str, col_map: dict, now: datetime ) -> tuple[list[TableConstraintInDB], list[ConstraintColumnInDB]]: + """ + 테이블의 제약조건 및 제약조건 컬럼 어노테이션 모델 리스트를 생성합니다. + """ constraint_annos, constraint_col_annos = [], [] for const_data in tbl_data.get("constraints", []): original_constraint = next((c for c in original_table.constraints if c.name == const_data["name"]), None) @@ -253,6 +272,9 @@ def _process_constraints( def _process_indexes( self, tbl_data: dict, original_table: UserDBTableInfo, table_id: str, col_map: dict, now: datetime ) -> tuple[list[IndexAnnotationInDB], list[IndexColumnInDB]]: + """ + 테이블의 인덱스 및 인덱스 컬럼 어노테이션 모델 리스트를 생성합니다. + """ index_annos, index_col_annos = [], [] for idx_data in tbl_data.get("indexes", []): original_index = next((i for i in original_table.indexes if i.name == idx_data["name"]), None) @@ -285,6 +307,9 @@ def _process_indexes( return index_annos, index_col_annos def get_full_annotation(self, annotation_id: str) -> FullAnnotationResponse: + """ + ID를 기반으로 완전한 어노테이션 정보를 조회합니다. + """ try: annotation = self.repository.find_full_annotation_by_id(annotation_id) if not annotation: @@ -294,6 +319,9 @@ def get_full_annotation(self, annotation_id: str) -> FullAnnotationResponse: raise APIException(CommonCode.FAIL_FIND_ANNOTATION) from e def delete_annotation(self, annotation_id: str) -> AnnotationDeleteResponse: + """ + ID를 기반으로 어노테이션 및 관련 하위 데이터를 모두 삭제합니다. + """ try: is_deleted = self.repository.delete_annotation_by_id(annotation_id) if not is_deleted: From 18328af32eeca88b3ae15ee9d0f0c8e988c1711e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=ED=98=95=EC=A7=84?= Date: Fri, 15 Aug 2025 17:02:11 +0900 Subject: [PATCH 10/12] =?UTF-8?q?feat:=20AI=20request=EC=97=90=20=EC=82=AC?= =?UTF-8?q?=EC=9A=A9=EB=90=A0=20=EB=AA=A8=EB=8D=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/schemas/annotation/ai_model.py | 64 ++++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 app/schemas/annotation/ai_model.py diff --git a/app/schemas/annotation/ai_model.py b/app/schemas/annotation/ai_model.py new file mode 100644 index 0000000..f902699 --- /dev/null +++ b/app/schemas/annotation/ai_model.py @@ -0,0 +1,64 @@ +from typing import Any + +from pydantic import BaseModel, Field + + +class AIColumnInfo(BaseModel): + """AI 요청을 위한 컬럼 정보 모델""" + + column_name: str = Field(..., description="컬럼 이름") + data_type: str = Field(..., description="데이터 타입") + is_pk: bool = Field(False, description="기본 키(Primary Key) 여부") + is_nullable: bool = Field(..., description="NULL 허용 여부") + default_value: Any | None = Field(None, description="기본값") + + +class AIConstraintInfo(BaseModel): + """AI 요청을 위한 제약 조건 정보 모델 (FK 제외)""" + + name: str | None = Field(None, description="제약 조건 이름") + type: str = Field(..., description="제약 조건 타입 (PRIMARY KEY, UNIQUE, CHECK)") + columns: list[str] = Field(..., description="제약 조건에 포함된 컬럼 목록") + check_expression: str | None = Field(None, description="CHECK 제약 조건 표현식") + + +class AIIndexInfo(BaseModel): + """AI 요청을 위한 인덱스 정보 모델""" + + name: str | None = Field(None, description="인덱스 이름") + columns: list[str] = Field(..., description="인덱스에 포함된 컬럼 목록") + is_unique: bool = Field(False, description="고유 인덱스 여부") + + +class AITableInfo(BaseModel): + """AI 요청을 위한 테이블 정보 모델""" + + table_name: str = Field(..., description="테이블 이름") + columns: list[AIColumnInfo] = Field(..., description="컬럼 목록") + constraints: list[AIConstraintInfo] = Field([], description="제약 조건 목록 (FK 제외)") + indexes: list[AIIndexInfo] = Field([], description="인덱스 목록") + sample_rows: list[dict[str, Any]] = Field([], description="테이블 샘플 데이터") + + +class AIRelationship(BaseModel): + """AI 요청을 위한 관계(FK) 정보 모델""" + + from_table: str = Field(..., description="관계를 시작하는 테이블") + from_columns: list[str] = Field(..., description="관계를 시작하는 컬럼") + to_table: str = Field(..., description="관계를 맺는 대상 테이블") + to_columns: list[str] = Field(..., description="관계를 맺는 대상 컬럼") + + +class AIDatabaseInfo(BaseModel): + """AI 요청을 위한 데이터베이스 정보 모델""" + + database_name: str = Field(..., description="데이터베이스 이름") + tables: list[AITableInfo] = Field(..., description="테이블 목록") + relationships: list[AIRelationship] = Field([], description="관계(FK) 목록") + + +class AIAnnotationRequest(BaseModel): + """AI 어노테이션 생성 요청 최상위 모델""" + + dbms_type: str = Field(..., description="DBMS 종류") + databases: list[AIDatabaseInfo] = Field(..., description="데이터베이스 목록") From 9015ae61b5a16ec3768390995aaea25212e98d9d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=ED=98=95=EC=A7=84?= Date: Fri, 15 Aug 2025 17:03:02 +0900 Subject: [PATCH 11/12] =?UTF-8?q?feat:=20AI=20=ED=8C=80=EC=97=90=EC=84=9C?= =?UTF-8?q?=20=EC=9A=94=EC=B2=AD=ED=95=9C=20sample=5Frows=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/repository/user_db_repository.py | 53 ++++++++++++++++++++++++++++ app/services/user_db_service.py | 20 ++++++++++- 2 files changed, 72 insertions(+), 1 deletion(-) diff --git a/app/repository/user_db_repository.py b/app/repository/user_db_repository.py index 0ddf8b3..191ea36 100644 --- a/app/repository/user_db_repository.py +++ b/app/repository/user_db_repository.py @@ -528,6 +528,59 @@ def _find_indexes_for_postgresql(self, cursor: Any, schema_name: str, table_name for name, data in index_map.items() ] + def find_sample_rows( + self, driver_module: Any, db_type: str, schema_name: str, table_names: list[str], **kwargs: Any + ) -> dict[str, list[dict[str, Any]]]: + """ + 주어진 테이블 목록에 대해 상위 3개의 샘플 행을 조회합니다. + - 실패 시 DB 드라이버의 예외를 직접 발생시킵니다. + """ + connection = None + try: + connection = self._connect(driver_module, **kwargs) + cursor = connection.cursor() + + if db_type == DBTypesEnum.sqlite.name: + return self._find_sample_rows_for_sqlite(cursor, table_names) + elif db_type == DBTypesEnum.postgresql.name: + return self._find_sample_rows_for_postgresql(cursor, schema_name, table_names) + # elif db_type == ...: + return {table_name: [] for table_name in table_names} + finally: + if connection: + connection.close() + + def _find_sample_rows_for_sqlite(self, cursor: Any, table_names: list[str]) -> dict[str, list[dict[str, Any]]]: + sample_rows_map = {} + for table_name in table_names: + try: + # 컬럼명 조회를 위해 PRAGMA 사용 + cursor.execute(f"PRAGMA table_info('{table_name}')") + columns = [row[1] for row in cursor.fetchall()] + + # 데이터 조회 + cursor.execute(f'SELECT * FROM "{table_name}" LIMIT 3') + rows = cursor.fetchall() + sample_rows_map[table_name] = [dict(zip(columns, row, strict=False)) for row in rows] + except Exception: + sample_rows_map[table_name] = [] # 오류 발생 시 빈 리스트 할당 + return sample_rows_map + + def _find_sample_rows_for_postgresql( + self, cursor: Any, schema_name: str, table_names: list[str] + ) -> dict[str, list[dict[str, Any]]]: + sample_rows_map = {} + for table_name in table_names: + try: + # PostgreSQL은 cursor.description을 통해 컬럼명을 바로 얻을 수 있음 + cursor.execute(f'SELECT * FROM "{schema_name}"."{table_name}" LIMIT 3') + columns = [desc[0] for desc in cursor.description] + rows = cursor.fetchall() + sample_rows_map[table_name] = [dict(zip(columns, row, strict=False)) for row in rows] + except Exception: + sample_rows_map[table_name] = [] + return sample_rows_map + # ───────────────────────────── # DB 연결 메서드 # ───────────────────────────── diff --git a/app/services/user_db_service.py b/app/services/user_db_service.py index eff501a..7b3454d 100644 --- a/app/services/user_db_service.py +++ b/app/services/user_db_service.py @@ -152,7 +152,7 @@ def find_columns( def get_full_schema_info( self, db_info: AllDBProfileInfo, repository: UserDbRepository = user_db_repository - ) -> SchemaInfoResult: + ) -> list[TableInfo]: """ DB 프로필 정보를 받아 해당 데이터베이스의 전체 스키마 정보 (테이블, 컬럼, 제약조건, 인덱스)를 조회하여 반환합니다. @@ -219,6 +219,24 @@ def get_full_schema_info( # 그 외 모든 예외는 일반 실패로 처리 raise APIException(CommonCode.FAIL) from e + def get_sample_rows( + self, db_info: AllDBProfileInfo, table_infos: list[TableInfo], repository: UserDbRepository = user_db_repository + ) -> dict[str, list[dict[str, Any]]]: + """ + 테이블 정보 목록을 받아 각 테이블의 샘플 데이터를 조회하여 반환합니다. + """ + try: + driver_module = self._get_driver_module(db_info.type) + connect_kwargs = self._prepare_connection_args(db_info) + + # SQLite는 스키마 이름이 필요 없음 + schema_name = db_info.name if db_info.type != "sqlite" else "" + table_names = [table.name for table in table_infos] + + return repository.find_sample_rows(driver_module, db_info.type, schema_name, table_names, **connect_kwargs) + except Exception as e: + raise APIException(CommonCode.FAIL_FIND_SAMPLE_ROWS) from e + def _get_driver_module(self, db_type: str): """ DB 타입에 따라 동적으로 드라이버 모듈을 로드합니다. From f615ce69efb51a0375cdaa986162cc95302d2caa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=ED=98=95=EC=A7=84?= Date: Fri, 15 Aug 2025 17:04:33 +0900 Subject: [PATCH 12/12] =?UTF-8?q?feat:=20AI=20=EC=9A=94=EC=B2=AD=20?= =?UTF-8?q?=EB=B3=B8=EB=AC=B8=20=EC=83=9D=EC=84=B1=20=EB=A9=94=EC=84=9C?= =?UTF-8?q?=EB=93=9C=20=EC=B6=94=EA=B0=80=20&=20relationships=20=EB=A6=AC?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/services/annotation_service.py | 122 +++++++++++++++++++++++++---- 1 file changed, 106 insertions(+), 16 deletions(-) diff --git a/app/services/annotation_service.py b/app/services/annotation_service.py index c7a7ce7..f90696c 100644 --- a/app/services/annotation_service.py +++ b/app/services/annotation_service.py @@ -10,6 +10,15 @@ from app.core.status import CommonCode from app.core.utils import generate_prefixed_uuid, get_db_path from app.repository.annotation_repository import AnnotationRepository, annotation_repository +from app.schemas.annotation.ai_model import ( + AIAnnotationRequest, + AIColumnInfo, + AIConstraintInfo, + AIDatabaseInfo, + AIIndexInfo, + AIRelationship, + AITableInfo, +) from app.schemas.annotation.db_model import ( ColumnAnnotationInDB, ConstraintColumnInDB, @@ -49,23 +58,29 @@ def __init__( async def create_annotation(self, request: AnnotationCreateRequest) -> FullAnnotationResponse: """ 어노테이션 생성을 위한 전체 프로세스를 관장합니다. - 1. DB 프로필 및 전체 스키마 정보 조회 - 2. TODO: AI 서버에 요청 (현재는 Mock 데이터 사용) - 3. 트랜잭션 내에서 전체 어노테이션 정보 저장 + 1. DB 프로필, 전체 스키마 정보, 샘플 데이터 조회 + 2. AI 서버에 요청할 데이터 모델 생성 + 3. TODO: AI 서버에 요청 (현재는 Mock 데이터 사용) + 4. 트랜잭션 내에서 전체 어노테이션 정보 저장 """ try: request.validate() except ValueError as e: raise APIException(CommonCode.INVALID_ANNOTATION_REQUEST, detail=str(e)) from e - # 1. DB 프로필 및 전체 스키마 정보 조회 + # 1. DB 프로필, 전체 스키마 정보, 샘플 데이터 조회 db_profile = self.user_db_service.find_profile(request.db_profile_id) full_schema_info = self.user_db_service.get_full_schema_info(db_profile) + sample_rows = self.user_db_service.get_sample_rows(db_profile, full_schema_info) + + # 2. AI 서버에 요청할 데이터 모델 생성 + ai_request_body = self._prepare_ai_request_body(db_profile, full_schema_info, sample_rows) + print(ai_request_body.model_dump_json(indent=2)) - # 2. AI 서버에 요청 (현재는 Mock 데이터 사용) - ai_response = await self._request_annotation_to_ai_server(full_schema_info) + # 3. AI 서버에 요청 (현재는 Mock 데이터 사용) + ai_response = await self._request_annotation_to_ai_server(ai_request_body) - # 3. 트랜잭션 내에서 전체 어노테이션 정보 저장 + # 4. 트랜잭션 내에서 전체 어노테이션 정보 저장 db_path = get_db_path() conn = None try: @@ -90,6 +105,68 @@ async def create_annotation(self, request: AnnotationCreateRequest) -> FullAnnot return self.get_full_annotation(annotation_id) + def _prepare_ai_request_body( + self, + db_profile: AllDBProfileInfo, + full_schema_info: list[UserDBTableInfo], + sample_rows: dict[str, list[dict[str, Any]]], + ) -> AIAnnotationRequest: + """ + AI 서버에 보낼 요청 본문을 Pydantic 모델로 생성합니다. + """ + ai_tables = [] + ai_relationships = [] + + for table_info in full_schema_info: + # FK 제약조건을 분리하여 relationships 목록 생성 + non_fk_constraints = [] + for const in table_info.constraints: + if const.type == "FOREIGN KEY" and const.referenced_table and const.referenced_columns: + ai_relationships.append( + AIRelationship( + from_table=table_info.name, + from_columns=const.columns, + to_table=const.referenced_table, + to_columns=const.referenced_columns, + ) + ) + else: + non_fk_constraints.append( + AIConstraintInfo( + name=const.name, + type=const.type, + columns=const.columns, + check_expression=const.check_expression, + ) + ) + + ai_table = AITableInfo( + table_name=table_info.name, + columns=[ + AIColumnInfo( + column_name=col.name, + data_type=col.type, + is_pk=col.is_pk, + is_nullable=col.nullable, + default_value=col.default, + ) + for col in table_info.columns + ], + constraints=non_fk_constraints, + indexes=[ + AIIndexInfo(name=idx.name, columns=idx.columns, is_unique=idx.is_unique) + for idx in table_info.indexes + ], + sample_rows=sample_rows.get(table_info.name, []), + ) + ai_tables.append(ai_table) + + ai_database = AIDatabaseInfo( + database_name=db_profile.name or db_profile.username, tables=ai_tables, relationships=ai_relationships + ) + + return AIAnnotationRequest(dbms_type=db_profile.type, databases=[ai_database]) + def _transform_ai_response_to_db_models( self, ai_response: dict[str, Any], @@ -330,13 +407,13 @@ def delete_annotation(self, annotation_id: str) -> AnnotationDeleteResponse: except sqlite3.Error as e: raise APIException(CommonCode.FAIL_DELETE_ANNOTATION) from e - async def _request_annotation_to_ai_server(self, schema_info: list[UserDBTableInfo]) -> dict: + async def _request_annotation_to_ai_server(self, ai_request: AIAnnotationRequest) -> dict: """AI 서버에 스키마 정보를 보내고 어노테이션을 받아옵니다.""" # 우선은 목업 데이터 활용 - return self._get_mock_ai_response(schema_info) + return self._get_mock_ai_response(ai_request) # Real implementation below - # request_body = {"database_schema": {"tables": [table.model_dump() for table in schema_info]}} + # request_body = ai_request.model_dump() # async with httpx.AsyncClient() as client: # try: # response = await client.post(AI_SERVER_URL, json=request_body, timeout=60.0) @@ -347,19 +424,21 @@ async def _request_annotation_to_ai_server(self, schema_info: list[UserDBTableIn # except httpx.RequestError as e: # raise APIException(CommonCode.FAIL_AI_SERVER_CONNECTION, detail=f"AI server connection failed: {e}") from e - def _get_mock_ai_response(self, schema_info: list[UserDBTableInfo]) -> dict: + def _get_mock_ai_response(self, ai_request: AIAnnotationRequest) -> dict: """테스트를 위한 Mock AI 서버 응답 생성""" + # 요청 데이터를 기반으로 동적으로 Mock 응답을 생성하도록 수정 + db_info = ai_request.databases[0] mock_response = { - "database_annotation": "Mock: 데이터베이스 전체에 대한 설명입니다.", + "database_annotation": f"Mock: '{db_info.database_name}' 데이터베이스 전체에 대한 설명입니다.", "tables": [], "relationships": [], } - for table in schema_info: + for table in db_info.tables: mock_table = { - "table_name": table.name, - "annotation": f"Mock: '{table.name}' 테이블에 대한 설명입니다.", + "table_name": table.table_name, + "annotation": f"Mock: '{table.table_name}' 테이블에 대한 설명입니다.", "columns": [ - {"column_name": col.name, "annotation": f"Mock: '{col.name}' 컬럼에 대한 설명입니다."} + {"column_name": col.column_name, "annotation": f"Mock: '{col.column_name}' 컬럼에 대한 설명입니다."} for col in table.columns ], "constraints": [ @@ -382,6 +461,17 @@ def _get_mock_ai_response(self, schema_info: list[UserDBTableInfo]) -> dict: ], } mock_response["tables"].append(mock_table) + + for rel in db_info.relationships: + mock_response["relationships"].append( + { + "from_table": rel.from_table, + "from_columns": rel.from_columns, + "to_table": rel.to_table, + "to_columns": rel.to_columns, + "annotation": f"Mock: '{rel.from_table}'과 '{rel.to_table}'의 관계 설명.", + } + ) return mock_response