sqlServerConnector 0.1.8__tar.gz → 0.1.10__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sqlServerConnector
3
- Version: 0.1.8
3
+ Version: 0.1.10
4
4
  Summary: A custom SQL Server Connector for ETL processes with Pandas
5
5
  Author-email: Nguyen Minh Son <nguyen.minhson1511@gmail.com>
6
6
  Project-URL: Homepage, https://github.com/johnnyb1509/sqlServerConnector
@@ -20,7 +20,11 @@ Requires-Dist: jupyterlab
20
20
  # SQL Server Connector
21
21
 
22
22
  Thư viện kết nối SQL Server chuyên dụng cho các tác vụ ETL, được tối ưu hóa cho **Pandas**, hỗ trợ **Tiếng Việt (Unicode)** và **Upsert (Merge)** hiệu năng cao.
23
- ## Update 0.1.7
23
+
24
+ ### **Update 0.1.9**
25
+ > Thay vì dùng bảng tạm (##Staging), sẽ dùng bảng vật lý tạm thời (Physical Staging Table) có tên chứa UUID (để đảm bảo duy nhất, không trùng lặp giữa các luồng chạy). Sau khi Upsert xong, ta sẽ DROP bảng này ngay lập tức. Cách này tương thích 100% với Pandas và SQLAlchemy.
26
+
27
+ ### **Update 0.1.7**
24
28
  > Sửa lỗi nhỏ liên quan đến việc upsert với các bảng có cột chứa Tiếng Việt
25
29
 
26
30
 
@@ -1,7 +1,11 @@
1
1
  # SQL Server Connector
2
2
 
3
3
  Thư viện kết nối SQL Server chuyên dụng cho các tác vụ ETL, được tối ưu hóa cho **Pandas**, hỗ trợ **Tiếng Việt (Unicode)** và **Upsert (Merge)** hiệu năng cao.
4
- ## Update 0.1.7
4
+
5
+ ### **Update 0.1.9**
6
+ > Thay vì dùng bảng tạm (##Staging), sẽ dùng bảng vật lý tạm thời (Physical Staging Table) có tên chứa UUID (để đảm bảo duy nhất, không trùng lặp giữa các luồng chạy). Sau khi Upsert xong, ta sẽ DROP bảng này ngay lập tức. Cách này tương thích 100% với Pandas và SQLAlchemy.
7
+
8
+ ### **Update 0.1.7**
5
9
  > Sửa lỗi nhỏ liên quan đến việc upsert với các bảng có cột chứa Tiếng Việt
6
10
 
7
11
 
@@ -6,7 +6,7 @@ build-backend = "setuptools.build_meta"
6
6
 
7
7
  [project]
8
8
  name = "sqlServerConnector"
9
- version = "0.1.8"
9
+ version = "0.1.10"
10
10
  description = "A custom SQL Server Connector for ETL processes with Pandas"
11
11
  readme = "README.md"
12
12
  requires-python = ">=3.8"
@@ -0,0 +1,385 @@
1
+ import os
2
+ import pandas as pd
3
+ import numpy as np
4
+ import uuid
5
+ import sqlalchemy
6
+ from typing import List, Optional, Dict, Union, Any, Literal
7
+ from loguru import logger
8
+ from sqlalchemy import create_engine, text, URL, inspect
9
+ from sqlalchemy.types import NVARCHAR, FLOAT, INTEGER, DATE, DATETIME, BIGINT
10
+
11
+ class SQLServerConnector:
12
+ """
13
+ Trình kết nối SQL Server chuẩn hóa (Full Features - Fixed Missing Attribute).
14
+ Tích hợp:
15
+ - Fast Executemany (Tốc độ cao).
16
+ - Unicode Support (NVARCHAR).
17
+ - Upsert Strategy (Last/Skip).
18
+ - Schema Evolution.
19
+ - Helper methods: check_table_exists.
20
+ """
21
+
22
+ def __init__(self, server: str, database: str, username: str, password: str, driver: str = 'ODBC Driver 17 for SQL Server', **kwargs):
23
+ self.server = server
24
+ self.database = database
25
+ self.username = username
26
+ self.password = password
27
+ self.driver = driver
28
+
29
+ # Tạo URL kết nối
30
+ self.connection_url = URL.create(
31
+ "mssql+pyodbc",
32
+ query={
33
+ "odbc_connect": (
34
+ f"DRIVER={self.driver};"
35
+ f"SERVER={self.server};"
36
+ f"DATABASE={self.database};"
37
+ f"UID={self.username};"
38
+ f"PWD={self.password};"
39
+ "Encrypt=no;TrustServerCertificate=yes;"
40
+ )
41
+ }
42
+ )
43
+
44
+ # Engine với fast_executemany=True
45
+ self.engine = create_engine(
46
+ self.connection_url,
47
+ fast_executemany=True,
48
+ pool_pre_ping=True
49
+ )
50
+
51
+ def get_data(self, query: str, params: Optional[Dict] = None) -> pd.DataFrame:
52
+ """Thực thi SELECT và trả về DataFrame"""
53
+ try:
54
+ with self.engine.connect() as conn:
55
+ return pd.read_sql(text(query), conn, params=params)
56
+ except Exception as e:
57
+ logger.error(f"Get data error: {e}")
58
+ raise e
59
+
60
+ def execute_query(self, query: str, params: Optional[Dict] = None):
61
+ """Thực thi lệnh không trả về dữ liệu (UPDATE, DELETE, etc.)"""
62
+ try:
63
+ with self.engine.begin() as conn:
64
+ conn.execute(text(query), params or {})
65
+ except Exception as e:
66
+ logger.error(f"Execute query error: {e}")
67
+ raise e
68
+
69
+ # --- [ĐÃ BỔ SUNG LẠI HÀM NÀY] ---
70
+ def check_table_exists(self, table_name: str) -> bool:
71
+ """Kiểm tra bảng có tồn tại trong database không"""
72
+ try:
73
+ inspector = inspect(self.engine)
74
+ return inspector.has_table(table_name)
75
+ except Exception as e:
76
+ logger.error(f"Check table exists failed: {e}")
77
+ return False
78
+ # --------------------------------
79
+
80
+ def _generate_dtype_mapping(self, df: pd.DataFrame) -> Dict:
81
+ """Tự động map kiểu dữ liệu (NVARCHAR cho string)"""
82
+ dtype_map = {}
83
+ for col in df.columns:
84
+ if df[col].dtype == 'object' or pd.api.types.is_string_dtype(df[col]):
85
+ dtype_map[col] = NVARCHAR(length=None)
86
+ elif pd.api.types.is_datetime64_any_dtype(df[col]):
87
+ dtype_map[col] = DATETIME()
88
+ elif pd.api.types.is_float_dtype(df[col]):
89
+ dtype_map[col] = FLOAT()
90
+ elif pd.api.types.is_integer_dtype(df[col]):
91
+ dtype_map[col] = BIGINT()
92
+ return dtype_map
93
+
94
+ def _get_table_columns(self, table_name: str, conn) -> List[str]:
95
+ """Lấy danh sách cột hiện có trong DB"""
96
+ inspector = inspect(conn)
97
+ columns = [col['name'] for col in inspector.get_columns(table_name)]
98
+ return columns
99
+
100
+ def _add_missing_columns(self, table_name: str, missing_cols: List[str], dtype_map: Dict, conn):
101
+ """Alter table để thêm cột thiếu (Schema Evolution)"""
102
+ for col in missing_cols:
103
+ col_type = dtype_map.get(col, NVARCHAR(255))
104
+ # SQLAlchemy type to string conversion logic simplified
105
+ type_str = "NVARCHAR(MAX)" # Default safe fallback
106
+ if isinstance(col_type, FLOAT): type_str = "FLOAT"
107
+ elif isinstance(col_type, BIGINT): type_str = "BIGINT"
108
+ elif isinstance(col_type, DATETIME): type_str = "DATETIME"
109
+ elif isinstance(col_type, DATE): type_str = "DATE"
110
+
111
+ alter_sql = f"ALTER TABLE [{table_name}] ADD [{col}] {type_str}"
112
+ conn.execute(text(alter_sql))
113
+ logger.info(f"Auto-evolve: Added column '{col}' to table '{table_name}'")
114
+
115
+ import os
116
+ import pandas as pd
117
+ import numpy as np
118
+ import uuid
119
+ import sqlalchemy
120
+ from typing import List, Optional, Dict, Union, Any, Literal
121
+ from loguru import logger
122
+ from sqlalchemy import create_engine, text, URL, inspect
123
+ from sqlalchemy.types import NVARCHAR, FLOAT, INTEGER, DATE, DATETIME, BIGINT
124
+
125
+ class SQLServerConnector:
126
+ """
127
+ Trình kết nối SQL Server chuẩn hóa (Ultimate Version).
128
+
129
+ Features:
130
+ - Core: Fast Executemany, Unicode (NVARCHAR), Physical Staging Tables.
131
+ - Features: Upsert (Merge), Schema Evolution, Auto Create Table.
132
+ - Strategies: 'last' (Update), 'skip' (Ignore), 'sum' (Aggregate numeric).
133
+ """
134
+
135
+ def __init__(self, server: str, database: str, username: str, password: str, driver: str = 'ODBC Driver 17 for SQL Server', **kwargs):
136
+ self.server = server
137
+ self.database = database
138
+ self.username = username
139
+ self.password = password
140
+ self.driver = driver
141
+
142
+ self.connection_url = URL.create(
143
+ "mssql+pyodbc",
144
+ query={
145
+ "odbc_connect": (
146
+ f"DRIVER={self.driver};"
147
+ f"SERVER={self.server};"
148
+ f"DATABASE={self.database};"
149
+ f"UID={self.username};"
150
+ f"PWD={self.password};"
151
+ "Encrypt=no;TrustServerCertificate=yes;"
152
+ )
153
+ }
154
+ )
155
+
156
+ # Engine với fast_executemany=True
157
+ self.engine = create_engine(
158
+ self.connection_url,
159
+ fast_executemany=True,
160
+ pool_pre_ping=True
161
+ )
162
+
163
+ def get_data(self, query: str, params: Optional[Dict] = None) -> pd.DataFrame:
164
+ """Thực thi SELECT và trả về DataFrame"""
165
+ try:
166
+ with self.engine.connect() as conn:
167
+ return pd.read_sql(text(query), conn, params=params)
168
+ except Exception as e:
169
+ logger.error(f"Get data error: {e}")
170
+ raise e
171
+
172
+ def execute_query(self, query: str, params: Optional[Dict] = None):
173
+ """Thực thi lệnh không trả về dữ liệu"""
174
+ try:
175
+ with self.engine.begin() as conn:
176
+ conn.execute(text(query), params or {})
177
+ except Exception as e:
178
+ logger.error(f"Execute query error: {e}")
179
+ raise e
180
+
181
+ def check_table_exists(self, table_name: str) -> bool:
182
+ """Kiểm tra bảng có tồn tại không"""
183
+ try:
184
+ inspector = inspect(self.engine)
185
+ return inspector.has_table(table_name)
186
+ except Exception as e:
187
+ logger.error(f"Check table exists failed: {e}")
188
+ return False
189
+
190
+ def _generate_dtype_mapping(self, df: pd.DataFrame) -> Dict:
191
+ """Tự động map kiểu dữ liệu (NVARCHAR cho string)"""
192
+ dtype_map = {}
193
+ for col in df.columns:
194
+ if df[col].dtype == 'object' or pd.api.types.is_string_dtype(df[col]):
195
+ dtype_map[col] = NVARCHAR(length=None)
196
+ elif pd.api.types.is_datetime64_any_dtype(df[col]):
197
+ dtype_map[col] = DATETIME()
198
+ elif pd.api.types.is_float_dtype(df[col]):
199
+ dtype_map[col] = FLOAT()
200
+ elif pd.api.types.is_integer_dtype(df[col]):
201
+ dtype_map[col] = BIGINT()
202
+ return dtype_map
203
+
204
+ def _get_table_columns(self, table_name: str, conn) -> List[str]:
205
+ """Lấy danh sách cột hiện có trong DB"""
206
+ inspector = inspect(conn)
207
+ columns = [col['name'] for col in inspector.get_columns(table_name)]
208
+ return columns
209
+
210
+ def _add_missing_columns(self, table_name: str, missing_cols: List[str], dtype_map: Dict, conn):
211
+ """Alter table để thêm cột thiếu"""
212
+ for col in missing_cols:
213
+ col_type = dtype_map.get(col, NVARCHAR(255))
214
+ type_str = "NVARCHAR(MAX)"
215
+ if isinstance(col_type, FLOAT): type_str = "FLOAT"
216
+ elif isinstance(col_type, BIGINT): type_str = "BIGINT"
217
+ elif isinstance(col_type, DATETIME): type_str = "DATETIME"
218
+ elif isinstance(col_type, DATE): type_str = "DATE"
219
+
220
+ conn.execute(text(f"ALTER TABLE [{table_name}] ADD [{col}] {type_str}"))
221
+ logger.info(f"Auto-evolve: Added column '{col}' to table '{table_name}'")
222
+
223
+ def upsert_data(self,
224
+ df: pd.DataFrame,
225
+ target_table: str,
226
+ primary_key: Union[str, List[str]] = None,
227
+ match_columns: Optional[List[str]] = None,
228
+ auto_evolve_schema: bool = True,
229
+ conflict_strategy: Literal['sum', 'last', 'skip'] = 'last'):
230
+ """
231
+ Hàm Upsert đa năng (Hợp nhất Logic cũ và mới).
232
+
233
+ Args:
234
+ df: DataFrame đầu vào.
235
+ target_table: Tên bảng đích.
236
+ primary_key: Tên cột khóa chính (str hoặc list) - Param cũ.
237
+ match_columns: Danh sách cột khóa chính - Param mới.
238
+ auto_evolve_schema: Tự động thêm cột mới vào DB.
239
+ conflict_strategy:
240
+ - 'sum': Cộng dồn các cột số, lấy giá trị đầu tiên các cột khác (Logic tài chính).
241
+ - 'last': Update ghi đè (Logic thông tin mới nhất).
242
+ - 'skip': Bỏ qua nếu trùng khóa.
243
+ """
244
+ if df.empty:
245
+ return
246
+
247
+ # 1. Xác định Join Keys (Hợp nhất 2 param)
248
+ join_keys = []
249
+ if match_columns:
250
+ join_keys = match_columns
251
+ elif primary_key:
252
+ join_keys = [primary_key] if isinstance(primary_key, str) else primary_key
253
+
254
+ if not join_keys:
255
+ logger.warning(f"No keys provided for {target_table}. Switching to APPEND mode.")
256
+
257
+ # 2. Xử lý Logic 'SUM' (Aggregation tại Python trước khi đẩy DB)
258
+ # Đây là logic quý giá từ code cũ của bạn
259
+ if conflict_strategy == 'sum' and join_keys:
260
+ # Xác định cột số
261
+ num_cols = df.select_dtypes(include=[np.number]).columns.tolist()
262
+ num_cols = [c for c in num_cols if c not in join_keys]
263
+
264
+ # Logic Aggregation
265
+ agg_logic = {col: 'sum' for col in num_cols}
266
+
267
+ # Các cột còn lại (text, date...) lấy dòng đầu tiên
268
+ other_cols = [c for c in df.columns if c not in join_keys and c not in num_cols]
269
+ for c in other_cols:
270
+ agg_logic[c] = 'first'
271
+
272
+ # Thực hiện GroupBy
273
+ initial_len = len(df)
274
+ df = df.groupby(join_keys, as_index=False).agg(agg_logic)
275
+
276
+ if len(df) < initial_len:
277
+ logger.info(f"Strategy 'sum': Aggregated {initial_len} -> {len(df)} rows.")
278
+
279
+ # 3. Map Unicode Types
280
+ dtype_mapping = self._generate_dtype_mapping(df)
281
+
282
+ # 4. Staging Table Name (Dùng bảng vật lý unique)
283
+ staging_table = f"Staging_{uuid.uuid4().hex[:10]}"
284
+
285
+ try:
286
+ with self.engine.begin() as conn:
287
+ # --- A. Kiểm tra & Tạo bảng đích nếu chưa có ---
288
+ inspector = inspect(conn)
289
+ if not inspector.has_table(target_table):
290
+ logger.info(f"Table {target_table} not found. Creating new...")
291
+ df.to_sql(target_table, conn, index=False, dtype=dtype_mapping)
292
+
293
+ if join_keys:
294
+ pk_str = ", ".join([f"[{c}]" for c in join_keys])
295
+ try:
296
+ conn.execute(text(f"ALTER TABLE [{target_table}] ADD CONSTRAINT PK_{target_table.replace('.','_')}_{uuid.uuid4().hex[:4]} PRIMARY KEY ({pk_str})"))
297
+ except Exception as e:
298
+ logger.warning(f"Could not create PK: {e}")
299
+ return # Đã insert xong do tạo bảng mới
300
+
301
+ # --- B. Schema Evolution (Code cũ gọi là _sync_columns) ---
302
+ db_cols = self._get_table_columns(target_table, conn)
303
+ df_cols = list(df.columns)
304
+ new_cols = [c for c in df_cols if c not in db_cols]
305
+
306
+ if new_cols:
307
+ if auto_evolve_schema:
308
+ self._add_missing_columns(target_table, new_cols, dtype_mapping, conn)
309
+ db_cols.extend(new_cols)
310
+ else:
311
+ # Nếu không evolve, bỏ cột thừa đi
312
+ valid_cols = [c for c in df_cols if c in db_cols]
313
+ df = df[valid_cols]
314
+
315
+ # --- C. Đẩy vào Staging ---
316
+ df.to_sql(
317
+ name=staging_table,
318
+ con=conn,
319
+ if_exists='replace',
320
+ index=False,
321
+ dtype=dtype_mapping
322
+ )
323
+
324
+ # --- D. MERGE Logic ---
325
+ # Nếu strategy là 'sum', sau khi aggregate ở Python, nó trở thành 'update' (ghi đè kết quả tổng vào DB)
326
+ # Hoặc nếu DB đã có số liệu, logic Merge chuẩn là update lại số mới.
327
+
328
+ if join_keys:
329
+ common_cols = [c for c in df.columns if c in db_cols]
330
+ on_clause = " AND ".join([f"Target.[{col}] = Source.[{col}]" for col in join_keys])
331
+
332
+ insert_cols = ", ".join([f"[{col}]" for col in common_cols])
333
+ insert_vals = ", ".join([f"Source.[{col}]" for col in common_cols])
334
+
335
+ merge_sql = ""
336
+
337
+ # Logic: 'last' hoặc 'sum' (sau khi agg) đều là UPDATE
338
+ if conflict_strategy in ['last', 'sum']:
339
+ update_cols = [c for c in common_cols if c not in join_keys]
340
+ if update_cols:
341
+ update_set = ", ".join([f"Target.[{col}] = Source.[{col}]" for col in update_cols])
342
+ merge_sql = f"""
343
+ MERGE [{target_table}] AS Target USING [{staging_table}] AS Source
344
+ ON {on_clause}
345
+ WHEN MATCHED THEN UPDATE SET {update_set}
346
+ WHEN NOT MATCHED BY TARGET THEN INSERT ({insert_cols}) VALUES ({insert_vals});
347
+ """
348
+ else:
349
+ # Chỉ có Key, Insert if not exists
350
+ merge_sql = f"""
351
+ MERGE [{target_table}] AS Target USING [{staging_table}] AS Source
352
+ ON {on_clause}
353
+ WHEN NOT MATCHED BY TARGET THEN INSERT ({insert_cols}) VALUES ({insert_vals});
354
+ """
355
+
356
+ elif conflict_strategy == 'skip':
357
+ merge_sql = f"""
358
+ MERGE [{target_table}] AS Target USING [{staging_table}] AS Source
359
+ ON {on_clause}
360
+ WHEN NOT MATCHED BY TARGET THEN INSERT ({insert_cols}) VALUES ({insert_vals});
361
+ """
362
+
363
+ conn.execute(text(merge_sql))
364
+ logger.success(f"Upserted {len(df)} rows to {target_table} (Strategy: {conflict_strategy})")
365
+ else:
366
+ # Append Mode
367
+ insert_cols = ", ".join([f"[{col}]" for col in df.columns])
368
+ conn.execute(text(f"INSERT INTO [{target_table}] ({insert_cols}) SELECT {insert_cols} FROM [{staging_table}]"))
369
+ logger.info(f"Appended {len(df)} rows to {target_table}")
370
+
371
+ except Exception as e:
372
+ logger.error(f"Upsert failed for {target_table}: {e}")
373
+ raise e
374
+ finally:
375
+ try:
376
+ with self.engine.begin() as conn:
377
+ conn.execute(text(f"IF OBJECT_ID('{staging_table}', 'U') IS NOT NULL DROP TABLE [{staging_table}]"))
378
+ except Exception:
379
+ pass
380
+
381
+ def dispose(self):
382
+ self.engine.dispose()
383
+
384
+ def dispose(self):
385
+ self.engine.dispose()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sqlServerConnector
3
- Version: 0.1.8
3
+ Version: 0.1.10
4
4
  Summary: A custom SQL Server Connector for ETL processes with Pandas
5
5
  Author-email: Nguyen Minh Son <nguyen.minhson1511@gmail.com>
6
6
  Project-URL: Homepage, https://github.com/johnnyb1509/sqlServerConnector
@@ -20,7 +20,11 @@ Requires-Dist: jupyterlab
20
20
  # SQL Server Connector
21
21
 
22
22
  Thư viện kết nối SQL Server chuyên dụng cho các tác vụ ETL, được tối ưu hóa cho **Pandas**, hỗ trợ **Tiếng Việt (Unicode)** và **Upsert (Merge)** hiệu năng cao.
23
- ## Update 0.1.7
23
+
24
+ ### **Update 0.1.9**
25
+ > Thay vì dùng bảng tạm (##Staging), sẽ dùng bảng vật lý tạm thời (Physical Staging Table) có tên chứa UUID (để đảm bảo duy nhất, không trùng lặp giữa các luồng chạy). Sau khi Upsert xong, ta sẽ DROP bảng này ngay lập tức. Cách này tương thích 100% với Pandas và SQLAlchemy.
26
+
27
+ ### **Update 0.1.7**
24
28
  > Sửa lỗi nhỏ liên quan đến việc upsert với các bảng có cột chứa Tiếng Việt
25
29
 
26
30
 
@@ -1,244 +0,0 @@
1
- import os
2
- import pandas as pd
3
- import numpy as np
4
- import uuid
5
- import sqlalchemy
6
- from typing import List, Optional, Dict, Union, Any, Literal
7
- from loguru import logger
8
- from sqlalchemy import create_engine, text, URL, inspect
9
- from sqlalchemy.types import NVARCHAR, FLOAT, INTEGER, DATE, DATETIME, BIGINT
10
-
11
- class SQLServerConnector:
12
- """
13
- Trình kết nối SQL Server chuẩn hóa (Full Features - Fixed Missing Attribute).
14
- Tích hợp:
15
- - Fast Executemany (Tốc độ cao).
16
- - Unicode Support (NVARCHAR).
17
- - Upsert Strategy (Last/Skip).
18
- - Schema Evolution.
19
- - Helper methods: check_table_exists.
20
- """
21
-
22
- def __init__(self, server: str, database: str, username: str, password: str, driver: str = 'ODBC Driver 17 for SQL Server', **kwargs):
23
- self.server = server
24
- self.database = database
25
- self.username = username
26
- self.password = password
27
- self.driver = driver
28
-
29
- # Tạo URL kết nối
30
- self.connection_url = URL.create(
31
- "mssql+pyodbc",
32
- query={
33
- "odbc_connect": (
34
- f"DRIVER={self.driver};"
35
- f"SERVER={self.server};"
36
- f"DATABASE={self.database};"
37
- f"UID={self.username};"
38
- f"PWD={self.password};"
39
- "Encrypt=no;TrustServerCertificate=yes;"
40
- )
41
- }
42
- )
43
-
44
- # Engine với fast_executemany=True
45
- self.engine = create_engine(
46
- self.connection_url,
47
- fast_executemany=True,
48
- pool_pre_ping=True
49
- )
50
-
51
- def get_data(self, query: str, params: Optional[Dict] = None) -> pd.DataFrame:
52
- """Thực thi SELECT và trả về DataFrame"""
53
- try:
54
- with self.engine.connect() as conn:
55
- return pd.read_sql(text(query), conn, params=params)
56
- except Exception as e:
57
- logger.error(f"Get data error: {e}")
58
- raise e
59
-
60
- def execute_query(self, query: str, params: Optional[Dict] = None):
61
- """Thực thi lệnh không trả về dữ liệu (UPDATE, DELETE, etc.)"""
62
- try:
63
- with self.engine.begin() as conn:
64
- conn.execute(text(query), params or {})
65
- except Exception as e:
66
- logger.error(f"Execute query error: {e}")
67
- raise e
68
-
69
- # --- [ĐÃ BỔ SUNG LẠI HÀM NÀY] ---
70
- def check_table_exists(self, table_name: str) -> bool:
71
- """Kiểm tra bảng có tồn tại trong database không"""
72
- try:
73
- inspector = inspect(self.engine)
74
- return inspector.has_table(table_name)
75
- except Exception as e:
76
- logger.error(f"Check table exists failed: {e}")
77
- return False
78
- # --------------------------------
79
-
80
- def _generate_dtype_mapping(self, df: pd.DataFrame) -> Dict:
81
- """Tự động map kiểu dữ liệu (NVARCHAR cho string)"""
82
- dtype_map = {}
83
- for col in df.columns:
84
- if df[col].dtype == 'object' or pd.api.types.is_string_dtype(df[col]):
85
- dtype_map[col] = NVARCHAR(length=None)
86
- elif pd.api.types.is_datetime64_any_dtype(df[col]):
87
- dtype_map[col] = DATETIME()
88
- elif pd.api.types.is_float_dtype(df[col]):
89
- dtype_map[col] = FLOAT()
90
- elif pd.api.types.is_integer_dtype(df[col]):
91
- dtype_map[col] = BIGINT()
92
- return dtype_map
93
-
94
- def _get_table_columns(self, table_name: str, conn) -> List[str]:
95
- """Lấy danh sách cột hiện có trong DB"""
96
- inspector = inspect(conn)
97
- columns = [col['name'] for col in inspector.get_columns(table_name)]
98
- return columns
99
-
100
- def _add_missing_columns(self, table_name: str, missing_cols: List[str], dtype_map: Dict, conn):
101
- """Alter table để thêm cột thiếu (Schema Evolution)"""
102
- for col in missing_cols:
103
- col_type = dtype_map.get(col, NVARCHAR(255))
104
- # SQLAlchemy type to string conversion logic simplified
105
- type_str = "NVARCHAR(MAX)" # Default safe fallback
106
- if isinstance(col_type, FLOAT): type_str = "FLOAT"
107
- elif isinstance(col_type, BIGINT): type_str = "BIGINT"
108
- elif isinstance(col_type, DATETIME): type_str = "DATETIME"
109
- elif isinstance(col_type, DATE): type_str = "DATE"
110
-
111
- alter_sql = f"ALTER TABLE [{table_name}] ADD [{col}] {type_str}"
112
- conn.execute(text(alter_sql))
113
- logger.info(f"Auto-evolve: Added column '{col}' to table '{table_name}'")
114
-
115
- def upsert_data(self,
116
- df: pd.DataFrame,
117
- target_table: str,
118
- match_columns: List[str],
119
- conflict_strategy: Literal['last', 'skip'] = 'last',
120
- auto_evolve_schema: bool = False):
121
- """
122
- Hàm Upsert mạnh mẽ.
123
-
124
- Args:
125
- df: DataFrame cần upload.
126
- target_table: Tên bảng đích.
127
- match_columns: Danh sách cột dùng làm Key so khớp (Primary Key).
128
- conflict_strategy:
129
- - 'last': Update ghi đè dữ liệu mới vào dòng cũ (Default).
130
- - 'skip': Nếu trùng key thì bỏ qua, không update.
131
- auto_evolve_schema:
132
- - True: Tự động thêm cột vào DB nếu DF có cột mới.
133
- - False: Bỏ qua các cột trong DF mà DB không có (Strict Schema).
134
- """
135
- if df.empty:
136
- logger.warning(f"DataFrame for {target_table} is empty. Skip.")
137
- return
138
-
139
- # 1. Map Unicode Types
140
- dtype_mapping = self._generate_dtype_mapping(df)
141
-
142
- # 2. Staging Table Name
143
- staging_table = f"##Staging_{uuid.uuid4().hex[:8]}"
144
-
145
- try:
146
- with self.engine.begin() as conn:
147
- # --- A. Kiểm tra Schema & Table ---
148
- inspector = inspect(conn)
149
- if not inspector.has_table(target_table):
150
- logger.info(f"Table {target_table} not found. Creating new...")
151
- df.to_sql(target_table, conn, index=False, dtype=dtype_mapping)
152
- # Tạo Primary Key nếu cần
153
- if match_columns:
154
- pk_str = ", ".join([f"[{c}]" for c in match_columns])
155
- try:
156
- conn.execute(text(f"ALTER TABLE [{target_table}] ADD CONSTRAINT PK_{target_table.replace('.','_')}_{uuid.uuid4().hex[:4]} PRIMARY KEY ({pk_str})"))
157
- except Exception as e:
158
- logger.warning(f"Could not create PK: {e}")
159
- return
160
-
161
- # --- B. Xử lý Schema Evolution ---
162
- db_cols = self._get_table_columns(target_table, conn)
163
- df_cols = list(df.columns)
164
-
165
- # Tìm cột có trong DF mà không có trong DB
166
- new_cols = [c for c in df_cols if c not in db_cols]
167
-
168
- if new_cols:
169
- if auto_evolve_schema:
170
- self._add_missing_columns(target_table, new_cols, dtype_mapping, conn)
171
- db_cols.extend(new_cols) # Update danh sách cột DB
172
- else:
173
- # Nếu không auto evolve, chỉ giữ lại các cột khớp với DB
174
- valid_cols = [c for c in df_cols if c in db_cols]
175
- if len(valid_cols) < len(df_cols):
176
- logger.warning(f"Schema strict: Dropping columns {new_cols} because they are not in DB.")
177
- df = df[valid_cols]
178
-
179
- # --- C. Đẩy vào Staging (Fast Executemany) ---
180
- df.to_sql(
181
- name=staging_table,
182
- con=conn,
183
- if_exists='replace',
184
- index=False,
185
- dtype=dtype_mapping
186
- )
187
-
188
- # --- D. Thực hiện MERGE ---
189
- # Chỉ lấy các cột chung giữa DF và DB để Merge (tránh lỗi cột không tồn tại)
190
- common_cols = [c for c in df.columns if c in db_cols]
191
-
192
- on_clause = " AND ".join([f"Target.[{col}] = Source.[{col}]" for col in match_columns])
193
-
194
- # Logic Insert
195
- insert_cols = ", ".join([f"[{col}]" for col in common_cols])
196
- insert_vals = ", ".join([f"Source.[{col}]" for col in common_cols])
197
-
198
- # Logic Update
199
- merge_sql = ""
200
-
201
- # Trường hợp 1: Update ('last')
202
- if conflict_strategy == 'last':
203
- update_cols = [c for c in common_cols if c not in match_columns]
204
- if update_cols:
205
- update_set = ", ".join([f"Target.[{col}] = Source.[{col}]" for col in update_cols])
206
- merge_sql = f"""
207
- MERGE [{target_table}] AS Target
208
- USING {staging_table} AS Source
209
- ON {on_clause}
210
- WHEN MATCHED THEN
211
- UPDATE SET {update_set}
212
- WHEN NOT MATCHED BY TARGET THEN
213
- INSERT ({insert_cols}) VALUES ({insert_vals});
214
- """
215
- else:
216
- # Nếu chỉ có cột PK, không có gì để update -> Chỉ Insert if not exists
217
- merge_sql = f"""
218
- MERGE [{target_table}] AS Target
219
- USING {staging_table} AS Source
220
- ON {on_clause}
221
- WHEN NOT MATCHED BY TARGET THEN
222
- INSERT ({insert_cols}) VALUES ({insert_vals});
223
- """
224
-
225
- # Trường hợp 2: Skip (Chỉ Insert, không Update)
226
- elif conflict_strategy == 'skip':
227
- merge_sql = f"""
228
- MERGE [{target_table}] AS Target
229
- USING {staging_table} AS Source
230
- ON {on_clause}
231
- WHEN NOT MATCHED BY TARGET THEN
232
- INSERT ({insert_cols}) VALUES ({insert_vals});
233
- """
234
-
235
- conn.execute(text(merge_sql))
236
- conn.execute(text(f"DROP TABLE IF EXISTS {staging_table}"))
237
- logger.info(f"Upserted {len(df)} rows to {target_table} (Strategy: {conflict_strategy})")
238
-
239
- except Exception as e:
240
- logger.error(f"Upsert failed for {target_table}: {e}")
241
- raise e
242
-
243
- def dispose(self):
244
- self.engine.dispose()