sqlServerConnector 0.1.5__tar.gz → 0.1.6__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.5
3
+ Version: 0.1.6
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,6 +20,9 @@ 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.6
24
+ > 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
+
23
26
 
24
27
  ## 🚀 Tính năng nổi bật
25
28
 
@@ -1,6 +1,9 @@
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.6
5
+ > 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
+
4
7
 
5
8
  ## 🚀 Tính năng nổi bật
6
9
 
@@ -6,7 +6,7 @@ build-backend = "setuptools.build_meta"
6
6
 
7
7
  [project]
8
8
  name = "sqlServerConnector"
9
- version = "0.1.5"
9
+ version = "0.1.6"
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,192 @@
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
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
+ from sqlalchemy.exc import SQLAlchemyError
11
+
12
+ class SQLServerConnector:
13
+ """
14
+ Trình kết nối SQL Server tối ưu cho ETL (Extract-Transform-Load).
15
+
16
+ Tính năng:
17
+ - Hỗ trợ Upsert (Merge) hiệu năng cao qua bảng tạm.
18
+ - Hỗ trợ Unicode (Tiếng Việt) tự động bằng NVARCHAR.
19
+ - Tự động quản lý Schema và Primary Key.
20
+ """
21
+
22
+ def __init__(self, server: str, database: str, username: str, password: str, driver: str = 'ODBC Driver 17 for SQL Server'):
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;" # Cấu hình SSL linh hoạt
40
+ )
41
+ }
42
+ )
43
+
44
+ # Tạo Engine với fast_executemany=True để tăng tốc độ Insert/Upsert
45
+ self.engine = create_engine(
46
+ self.connection_url,
47
+ fast_executemany=True, # QUAN TRỌNG: Tăng tốc độ ghi gấp nhiều lần
48
+ pool_pre_ping=True # Tự động kết nối lại nếu mất kết nối
49
+ )
50
+
51
+ def get_data(self, query: str, params: Optional[Dict] = None, chunksize: Optional[int] = None) -> Union[pd.DataFrame, Any]:
52
+ """
53
+ Thực thi câu lệnh SELECT và trả về DataFrame.
54
+ """
55
+ try:
56
+ with self.engine.connect() as conn:
57
+ # Dùng text() để đảm bảo tương thích SQLAlchemy 2.0
58
+ sql_query = text(query)
59
+ return pd.read_sql(sql_query, conn, params=params, chunksize=chunksize)
60
+ except Exception as e:
61
+ logger.error(f"Failed to retrieve data: {e}")
62
+ raise
63
+
64
+ def execute_query(self, query: str, params: Optional[Dict] = None):
65
+ """Thực thi câu lệnh không trả về dữ liệu (UPDATE, DELETE, SP...)."""
66
+ try:
67
+ with self.engine.begin() as conn: # Tự động commit
68
+ conn.execute(text(query), params or {})
69
+ except Exception as e:
70
+ logger.error(f"Failed to execute query: {e}")
71
+ raise
72
+
73
+ def _generate_dtype_mapping(self, df: pd.DataFrame) -> Dict:
74
+ """
75
+ Tự động tạo mapping kiểu dữ liệu cho SQL.
76
+ QUAN TRỌNG: Map tất cả cột string/object sang NVARCHAR để hỗ trợ Tiếng Việt.
77
+ """
78
+ dtype_map = {}
79
+ for col in df.columns:
80
+ # Nếu là chuỗi -> NVARCHAR (hỗ trợ Unicode)
81
+ if df[col].dtype == 'object' or pd.api.types.is_string_dtype(df[col]):
82
+ # Tính độ dài max thực tế để tối ưu, hoặc để None (NVARCHAR(MAX))
83
+ max_len = df[col].astype(str).map(len).max()
84
+ if pd.isna(max_len) or max_len == 0:
85
+ length = 255
86
+ else:
87
+ length = int(max_len * 1.5) + 50 # Buffer thêm
88
+ if length > 4000: length = None # NVARCHAR(MAX)
89
+
90
+ dtype_map[col] = NVARCHAR(length=length)
91
+
92
+ # Nếu là ngày tháng
93
+ elif pd.api.types.is_datetime64_any_dtype(df[col]):
94
+ dtype_map[col] = DATETIME()
95
+
96
+ # Số thực
97
+ elif pd.api.types.is_float_dtype(df[col]):
98
+ dtype_map[col] = FLOAT()
99
+
100
+ # Số nguyên
101
+ elif pd.api.types.is_integer_dtype(df[col]):
102
+ dtype_map[col] = BIGINT()
103
+
104
+ return dtype_map
105
+
106
+ def upsert_data(self, df: pd.DataFrame, table_name: str, pk_cols: List[str]):
107
+ """
108
+ Thực hiện Upsert (Insert hoặc Update) dữ liệu vào bảng SQL Server.
109
+ Sử dụng cơ chế Bảng Tạm (Staging Table) + MERGE Statement.
110
+ """
111
+ if df.empty:
112
+ logger.warning(f"DataFrame for table {table_name} is empty. Skipping upsert.")
113
+ return
114
+
115
+ # 1. Chuẩn bị tên bảng tạm
116
+ staging_table = f"##Staging_{uuid.uuid4().hex[:8]}"
117
+
118
+ # 2. Tạo mapping kiểu dữ liệu (Fix lỗi Unicode)
119
+ dtype_mapping = self._generate_dtype_mapping(df)
120
+
121
+ try:
122
+ with self.engine.begin() as conn:
123
+ # A. Đẩy dữ liệu vào bảng tạm (Staging)
124
+ # fast_executemany=True ở engine sẽ làm bước này cực nhanh
125
+ df.to_sql(
126
+ name=staging_table,
127
+ con=conn,
128
+ if_exists='replace',
129
+ index=False,
130
+ dtype=dtype_mapping # Ép kiểu NVARCHAR tại đây
131
+ )
132
+
133
+ # B. Kiểm tra bảng đích có tồn tại không
134
+ inspector = inspect(conn)
135
+ if not inspector.has_table(table_name):
136
+ logger.info(f"Table {table_name} does not exist. Creating from staging...")
137
+ # Tạo bảng chính từ bảng tạm (Copy cấu trúc và dữ liệu)
138
+ # Lưu ý: SELECT INTO sẽ tạo bảng mới
139
+ create_sql = f"SELECT * INTO {table_name} FROM {staging_table}"
140
+ conn.execute(text(create_sql))
141
+
142
+ # Tạo Primary Key cho bảng mới
143
+ if pk_cols:
144
+ pk_str = ", ".join([f"[{c}]" for c in pk_cols])
145
+ try:
146
+ alter_pk = f"ALTER TABLE {table_name} ADD CONSTRAINT PK_{table_name}_{uuid.uuid4().hex[:4]} PRIMARY KEY ({pk_str})"
147
+ conn.execute(text(alter_pk))
148
+ except Exception as ex_pk:
149
+ logger.warning(f"Could not create PK: {ex_pk}")
150
+ else:
151
+ # C. Thực hiện MERGE (Upsert)
152
+ # Lấy danh sách cột
153
+ cols = [c for c in df.columns]
154
+
155
+ # 1. Điều kiện ON (Primary Keys)
156
+ on_clause = " AND ".join([f"Target.[{col}] = Source.[{col}]" for col in pk_cols])
157
+
158
+ # 2. Điều kiện UPDATE (Các cột không phải PK)
159
+ update_cols = [col for col in cols if col not in pk_cols]
160
+ if update_cols:
161
+ update_clause = ", ".join([f"Target.[{col}] = Source.[{col}]" for col in update_cols])
162
+ matched_action = f"WHEN MATCHED THEN UPDATE SET {update_clause}"
163
+ else:
164
+ # Trường hợp bảng chỉ có PK (ít gặp), không làm gì khi match
165
+ matched_action = ""
166
+
167
+ # 3. Điều kiện INSERT (Tất cả cột)
168
+ insert_cols_str = ", ".join([f"[{col}]" for col in cols])
169
+ insert_vals_str = ", ".join([f"Source.[{col}]" for col in cols])
170
+
171
+ merge_sql = f"""
172
+ MERGE [{table_name}] AS Target
173
+ USING {staging_table} AS Source
174
+ ON {on_clause}
175
+ {matched_action}
176
+ WHEN NOT MATCHED BY TARGET THEN
177
+ INSERT ({insert_cols_str})
178
+ VALUES ({insert_vals_str});
179
+ """
180
+
181
+ conn.execute(text(merge_sql))
182
+ logger.info(f"Upserted {len(df)} rows into {table_name}.")
183
+
184
+ # D. Xóa bảng tạm (Optional, vì temp table ## tự hủy khi đóng conn, nhưng xóa cho sạch)
185
+ conn.execute(text(f"DROP TABLE IF EXISTS {staging_table}"))
186
+
187
+ except Exception as e:
188
+ logger.error(f"Upsert failed for {table_name}: {e}")
189
+ raise
190
+
191
+ def dispose(self):
192
+ self.engine.dispose()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sqlServerConnector
3
- Version: 0.1.5
3
+ Version: 0.1.6
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,6 +20,9 @@ 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.6
24
+ > 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
+
23
26
 
24
27
  ## 🚀 Tính năng nổi bật
25
28
 
@@ -1,222 +0,0 @@
1
- import os
2
- import numpy as np
3
- import pandas as pd
4
- import uuid
5
- from typing import List, Optional, Dict, Union, Any
6
- from loguru import logger
7
- from sqlalchemy import create_engine, inspect, text, URL
8
- from sqlalchemy.types import NVARCHAR, FLOAT, INTEGER, DATE, DATETIME, BIGINT
9
- from sqlalchemy.exc import SQLAlchemyError
10
-
11
- class SQLServerConnector:
12
- """
13
- A robust, SQLAlchemy 2.0 compliant connector for SQL Server designed for ETL processes.
14
-
15
- Features:
16
- - High-performance Upserts (Merge) using Unique Staging Tables.
17
- - Advanced Conflict Resolution: 'sum' (for finance) or 'last' (for metadata).
18
- - Automatic Schema Evolution and Primary Key management.
19
- - Unicode/Vietnamese support (NVARCHAR + UTF8).
20
- """
21
-
22
- def __init__(self, server: str, database: str, username: str, password: str, driver: str = 'ODBC Driver 17 for SQL Server'):
23
- self.server = server
24
- self.database = database
25
- self.username = username
26
- self.password = password
27
- self.driver = driver
28
-
29
- self.connection_url = URL.create(
30
- "mssql+pyodbc",
31
- query={
32
- "odbc_connect": (
33
- f"DRIVER={self.driver};"
34
- f"SERVER={self.server};"
35
- f"DATABASE={self.database};"
36
- f"UID={self.username};"
37
- f"PWD={self.password};"
38
- "Charsets=UTF-8;"
39
- ),
40
- "fast_executemany": "True"
41
- }
42
- )
43
-
44
- self.engine = create_engine(
45
- self.connection_url,
46
- pool_pre_ping=True,
47
- pool_size=20,
48
- max_overflow=10
49
- )
50
-
51
- def dispose(self):
52
- self.engine.dispose()
53
- logger.info("Database engine disposed.")
54
-
55
- # ========================================================
56
- # SCHEMA HELPERS
57
- # ========================================================
58
-
59
- def check_table_exists(self, table_name: str) -> bool:
60
- return inspect(self.engine).has_table(table_name)
61
-
62
- def get_columns_info(self, table_name: str) -> Dict[str, str]:
63
- inspector = inspect(self.engine)
64
- return {col['name']: str(col['type']) for col in inspector.get_columns(table_name)}
65
-
66
- # ========================================================
67
- # CORE ETL METHODS
68
- # ========================================================
69
-
70
- def upsert_data(self, df: pd.DataFrame, target_table: str, primary_key: str = None,
71
- match_columns: Optional[List[str]] = None, auto_evolve_schema: bool = True,
72
- conflict_strategy: str = 'sum'):
73
- if df.empty: return
74
-
75
- join_keys = match_columns if match_columns else ([primary_key] if primary_key else [])
76
-
77
- # 1. Sanitize & lọc lấy các cột cần thiết
78
- # Chỉ giữ lại join_keys và các cột có dữ liệu để tránh "phân mảnh" dữ liệu khi gộp
79
- df_clean = self._sanitize_dataframe(df, exclude_cols=join_keys)
80
-
81
- # 2. Xử lý trùng lặp triệt để
82
- initial_len = len(df_clean)
83
- if conflict_strategy == 'sum':
84
- # Xác định cột số để cộng dồn
85
- num_cols = df_clean.select_dtypes(include=[np.number]).columns.tolist()
86
- num_cols = [c for c in num_cols if c not in join_keys]
87
-
88
- # Chỉ gộp trên các cột số, các cột text khác key sẽ bị loại bỏ hoặc lấy dòng đầu
89
- # Điều này đảm bảo kết quả trả về CHỈ CÓ 1 DÒNG cho mỗi cặp Key
90
- agg_logic = {col: 'sum' for col in num_cols}
91
-
92
- # Đối với các cột không phải số và không phải key, chúng ta lấy dòng đầu tiên
93
- other_cols = [c for c in df_clean.columns if c not in join_keys and c not in num_cols]
94
- for c in other_cols:
95
- agg_logic[c] = 'first'
96
-
97
- df_clean = df_clean.groupby(join_keys, as_index=False).agg(agg_logic)
98
- else:
99
- df_clean = df_clean.drop_duplicates(subset=join_keys, keep='last')
100
-
101
- if len(df_clean) < initial_len:
102
- logger.info(f"Conflict Resolution ({conflict_strategy}): Combined {initial_len} -> {len(df_clean)} rows.")
103
-
104
- # 3. Schema Management
105
- if not self.check_table_exists(target_table):
106
- self._create_table_from_df(df_clean, target_table, primary_key)
107
- elif auto_evolve_schema:
108
- self._sync_columns(df_clean, target_table)
109
-
110
- # 4. Execute Merge
111
- self._execute_merge_upsert(df_clean, target_table, join_keys)
112
-
113
- def _execute_merge_upsert(self, df: pd.DataFrame, target_table: str, join_keys: List[str]):
114
- # Use a unique staging name to support parallel tasks
115
- unique_id = str(uuid.uuid4()).replace('-', '')[:10]
116
- staging_table = f"##stg_{unique_id}_{target_table[:20]}"
117
-
118
- with self.engine.begin() as conn:
119
- try:
120
- # Explicit mapping for Unicode
121
- dtype_map = {col: NVARCHAR(None) for col in df.columns if df[col].dtype == 'object'}
122
- df.to_sql(staging_table, conn, if_exists='replace', index=False, dtype=dtype_map)
123
-
124
- source_cols = list(df.columns)
125
- on_clause = " AND ".join([f"Target.[{k}] = Source.[{k}]" for k in join_keys])
126
- update_stmts = [f"Target.[{col}] = Source.[{col}]" for col in source_cols if col not in join_keys]
127
-
128
- insert_cols = ", ".join([f"[{col}]" for col in source_cols])
129
- insert_vals = ", ".join([f"Source.[{col}]" for col in source_cols])
130
-
131
- sql = f"""
132
- MERGE [{target_table}] AS Target USING [{staging_table}] AS Source
133
- ON ({on_clause})
134
- {f"WHEN MATCHED THEN UPDATE SET {', '.join(update_stmts)}" if update_stmts else ""}
135
- WHEN NOT MATCHED BY TARGET THEN INSERT ({insert_cols}) VALUES ({insert_vals});
136
- """
137
- conn.execute(text(sql))
138
- conn.execute(text(f"DROP TABLE [{staging_table}]"))
139
- logger.success(f"Successfully upserted {len(df)} rows to {target_table}.")
140
- except Exception as e:
141
- logger.error(f"Merge execution failed for {target_table}: {e}")
142
- raise
143
-
144
- # ========================================================
145
- # UTILS: CLEANING & SCHEMA
146
- # ========================================================
147
-
148
- def _sanitize_dataframe(self, df: pd.DataFrame, exclude_cols: List[str]) -> pd.DataFrame:
149
- df = df.copy()
150
- # Clean Dates
151
- for col in df.select_dtypes(include=['datetime']).columns:
152
- df[col] = df[col].replace({pd.NaT: None})
153
- # Clean NaN/None
154
- df = df.replace({np.nan: None})
155
- df = df.where(pd.notnull(df), None)
156
- return df
157
-
158
- def _create_table_from_df(self, df: pd.DataFrame, table_name: str, primary_key: Optional[str]):
159
- dtype_map = {col: NVARCHAR(None) for col in df.columns if df[col].dtype == 'object'}
160
- df.to_sql(table_name, self.engine, index=False, dtype=dtype_map)
161
- if primary_key and primary_key in df.columns:
162
- self.set_primary_key(table_name, primary_key, df[primary_key].dtype)
163
-
164
- def set_primary_key(self, table_name: str, column_name: str, source_dtype):
165
- sql_type = "NVARCHAR(450)" if pd.api.types.is_string_dtype(source_dtype) else "BIGINT"
166
- with self.engine.connect() as conn:
167
- with conn.begin():
168
- conn.execute(text(f"ALTER TABLE [{table_name}] ALTER COLUMN [{column_name}] {sql_type} NOT NULL"))
169
- conn.execute(text(f"ALTER TABLE [{table_name}] ADD PRIMARY KEY ([{column_name}])"))
170
-
171
- def _sync_columns(self, df: pd.DataFrame, table_name: str):
172
- db_cols = {k.lower() for k in self.get_columns_info(table_name).keys()}
173
- new_cols = [c for c in df.columns if c.lower() not in db_cols]
174
-
175
- if new_cols:
176
- with self.engine.connect() as conn:
177
- for col in new_cols:
178
- sql_type = "NVARCHAR(MAX)" if df[col].dtype == 'object' else "FLOAT"
179
- conn.execute(text(f"ALTER TABLE [{table_name}] ADD [{col}] {sql_type} NULL"))
180
- conn.commit()
181
-
182
- # ========================================================
183
- # DATA RETRIEVAL METHODS
184
- # ========================================================
185
- def get_data(self, query: str, params: Optional[Dict[str, Any]] = None, chunksize: Optional[int] = None) -> Union[pd.DataFrame, Any]:
186
- """
187
- Executes a SQL query and returns a Pandas DataFrame.
188
-
189
- Args:
190
- query (str): The SQL query string. Use :param_name for parameters.
191
- params (dict, optional): Dictionary of parameters to bind to the query.
192
- chunksize (int, optional): If specified, returns an iterator where each chunk is the given size.
193
-
194
- Returns:
195
- pd.DataFrame or Iterator[pd.DataFrame]
196
- """
197
- try:
198
- with self.engine.connect() as conn:
199
- # Use text() explicitly for SQLAlchemy 2.0 compatibility
200
- sql_query = text(query)
201
-
202
- # If chunksize is provided, read_sql returns a generator
203
- result = pd.read_sql(
204
- sql_query,
205
- conn,
206
- params=params,
207
- chunksize=chunksize
208
- )
209
-
210
- if chunksize is None:
211
- logger.info(f"Successfully retrieved {len(result)} rows.")
212
- else:
213
- logger.info(f"Retrieving data in chunks of {chunksize} rows.")
214
-
215
- return result
216
-
217
- except SQLAlchemyError as e:
218
- logger.error(f"Failed to retrieve data: {e}")
219
- raise
220
- except Exception as e:
221
- logger.error(f"An unexpected error occurred during get_data: {e}")
222
- raise