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.
- {sqlserverconnector-0.1.8 → sqlserverconnector-0.1.10}/PKG-INFO +6 -2
- {sqlserverconnector-0.1.8 → sqlserverconnector-0.1.10}/README.md +5 -1
- {sqlserverconnector-0.1.8 → sqlserverconnector-0.1.10}/pyproject.toml +1 -1
- sqlserverconnector-0.1.10/src/connector.py +385 -0
- {sqlserverconnector-0.1.8 → sqlserverconnector-0.1.10}/src/sqlServerConnector.egg-info/PKG-INFO +6 -2
- sqlserverconnector-0.1.8/src/connector.py +0 -244
- {sqlserverconnector-0.1.8 → sqlserverconnector-0.1.10}/setup.cfg +0 -0
- {sqlserverconnector-0.1.8 → sqlserverconnector-0.1.10}/src/__init__.py +0 -0
- {sqlserverconnector-0.1.8 → sqlserverconnector-0.1.10}/src/sqlServerConnector.egg-info/SOURCES.txt +0 -0
- {sqlserverconnector-0.1.8 → sqlserverconnector-0.1.10}/src/sqlServerConnector.egg-info/dependency_links.txt +0 -0
- {sqlserverconnector-0.1.8 → sqlserverconnector-0.1.10}/src/sqlServerConnector.egg-info/requires.txt +0 -0
- {sqlserverconnector-0.1.8 → sqlserverconnector-0.1.10}/src/sqlServerConnector.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: sqlServerConnector
|
|
3
|
-
Version: 0.1.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
|
@@ -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()
|
{sqlserverconnector-0.1.8 → sqlserverconnector-0.1.10}/src/sqlServerConnector.egg-info/PKG-INFO
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: sqlServerConnector
|
|
3
|
-
Version: 0.1.
|
|
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
|
-
|
|
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()
|
|
File without changes
|
|
File without changes
|
{sqlserverconnector-0.1.8 → sqlserverconnector-0.1.10}/src/sqlServerConnector.egg-info/SOURCES.txt
RENAMED
|
File without changes
|
|
File without changes
|
{sqlserverconnector-0.1.8 → sqlserverconnector-0.1.10}/src/sqlServerConnector.egg-info/requires.txt
RENAMED
|
File without changes
|
{sqlserverconnector-0.1.8 → sqlserverconnector-0.1.10}/src/sqlServerConnector.egg-info/top_level.txt
RENAMED
|
File without changes
|