Coverage for pgsql_upserter / upsert_engine.py: 94.05%

78 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-03-26 18:15 -0300

1"""Main upsert engine - public API for PostgreSQL upsert operations.""" 

2 

3import csv 

4import logging 

5import psycopg2 

6 

7from dataclasses import dataclass 

8from pathlib import Path 

9 

10from .schema_inspector import inspect_table_schema 

11from .column_matcher import match_columns 

12from .temp_staging import create_temp_table, bulk_insert_to_temp 

13from .conflict_resolver import ( 

14 find_conflict_strategy, 

15 deduplicate_temp_table, 

16 execute_upsert, 

17 DeduplicationResult, 

18 ConflictStrategy 

19) 

20from .config import create_connection_from_env, test_connection 

21 

22# Configure module logger 

23logger = logging.getLogger(__name__) 

24 

25 

26@dataclass 

27class UpsertResult: 

28 """Complete results from upsert workflow execution.""" 

29 rows_inserted: int 

30 rows_updated: int 

31 total_affected: int 

32 deduplication_result: DeduplicationResult 

33 matched_columns: list[str] 

34 conflict_strategy_type: str 

35 conflict_strategy_description: str 

36 

37 

38@staticmethod 

39def read_csv_to_dict_list(csv_path: str | Path) -> list[dict[str, str]]: 

40 """Read CSV file and return list of dictionaries. 

41 

42 Args: 

43 csv_path: Path to the CSV file 

44 

45 Returns: 

46 List of dictionaries with CSV data 

47 

48 Note: 

49 CSV data is returned as strings. Type conversion should be handled 

50 by the database driver during insertion. 

51 """ 

52 csv_file = Path(csv_path) 

53 data_list = [] 

54 with open(csv_file, 'r', encoding='utf-8') as f: 

55 reader = csv.DictReader(f) 

56 data_list = list(reader) 

57 return data_list 

58 

59 

60@staticmethod 

61def execute_upsert_workflow( 

62 connection: psycopg2.extensions.connection, 

63 data: list[dict[str, str]] | str | Path, 

64 target_table: str, 

65 conflict_columns: list[str] | None = None, 

66 update_columns: list[str] | None = None, 

67 batch_size: int = 1000, 

68 keep_temp_table: bool = False, 

69 schema: str = 'public' 

70) -> UpsertResult: 

71 """Execute complete upsert workflow with automatic conflict detection. 

72 

73 This function orchestrates the complete upsert process including: 

74 1. Data input handling (CSV files or direct data lists) 

75 2. Temporary table creation with automatic column detection 

76 3. Conflict strategy detection (primary key, unique constraints, or insert-only) 

77 4. Data deduplication using appropriate strategy 

78 5. Upsert execution with proper conflict resolution 

79 

80 Args: 

81 connection: Active PostgreSQL database connection 

82 target_table: Name of the target table for upsert operation 

83 data: Input data as list of dictionaries, CSV file path, or Path object 

84 conflict_columns: Optional override for conflict detection columns. If provided, 

85 these columns will be used for conflict resolution instead of 

86 automatic detection (primary keys, unique constraints) 

87 update_columns: Optional override for columns to update on conflict. If not 

88 provided, all matched columns will be updated 

89 batch_size: Number of rows to process in each batch during temp table population 

90 temp_table_prefix: Prefix for temporary table name (default: "_temp_") 

91 keep_temp_table: Whether to preserve temporary table after operation 

92 

93 Returns: 

94 UpsertResult: Object containing operation results and statistics 

95 

96 Raises: 

97 ValueError: If data is empty or target_table is invalid 

98 psycopg2.Error: For database connection or operation errors 

99 

100 Example: 

101 >>> # Using direct data 

102 >>> data = [{'id': 1, 'name': 'John'}, {'id': 2, 'name': 'Jane'}] 

103 >>> result = execute_upsert_workflow(conn, 'users', data) 

104 >>> print(f"Inserted: {result.inserted_count}, Updated: {result.updated_count}") 

105 

106 >>> # Using CSV file 

107 >>> result = execute_upsert_workflow(conn, 'users', 'data.csv') 

108 

109 Note: 

110 For serverless ETL: This function is designed to work seamlessly with 

111 API responses by accepting direct data lists, eliminating the need 

112 for intermediate CSV file creation in lambda/cloud functions. 

113 """ 

114 logger.info(f"Starting upsert workflow for table '{target_table}'") 

115 

116 # Step 1: Handle input data 

117 if isinstance(data, (str, Path)): 117 ↛ 118line 117 didn't jump to line 118 because the condition on line 117 was never true

118 logger.info(f"Reading CSV file: {data}") 

119 data_list = read_csv_to_dict_list(data) 

120 else: 

121 data_list = data 

122 

123 if not data_list: 

124 raise ValueError("No data provided for upsert operation") 

125 

126 logger.info(f"Processing {len(data_list)} rows") 

127 

128 # Step 2: Inspect target table schema 

129 target_schema = inspect_table_schema(connection, target_table, schema) 

130 logger.info("Target table schema inspected") 

131 

132 # Step 3: Match and map columns 

133 column_mapping = match_columns(data_list, target_schema) 

134 matched_columns = column_mapping['matched_columns'] 

135 logger.info(f"Matched columns: {matched_columns}") 

136 

137 # Step 4: Create and populate temp table 

138 # Create temp table with auto-generated name 

139 temp_table_name = create_temp_table(connection, target_table, schema) 

140 logger.info(f"Created temp table: {temp_table_name}") 

141 

142 # Populate temp table 

143 rows_inserted = bulk_insert_to_temp( 

144 connection=connection, 

145 temp_table_name=temp_table_name, 

146 data_list=data_list, 

147 matched_columns=matched_columns, 

148 target_schema=target_schema, 

149 batch_size=batch_size 

150 ) 

151 logger.info(f"Populated temp table with {rows_inserted} rows") 

152 

153 try: 

154 # Step 5: Find conflict strategy 

155 if conflict_columns: 

156 # Use user-provided conflict columns 

157 conflict_strategy = ConflictStrategy( 

158 type="USER_DEFINED", 

159 columns=conflict_columns, 

160 description=f"User-defined conflict resolution on: {conflict_columns}" 

161 ) 

162 logger.info(f"Using user-defined conflict strategy: {conflict_strategy.description}") 

163 else: 

164 # Automatic detection 

165 conflict_strategy = find_conflict_strategy( 

166 target_schema, 

167 matched_columns 

168 ) 

169 logger.info(f"Using automatic conflict strategy: {conflict_strategy.type}") 

170 

171 # Step 6: Deduplicate temp table 

172 dedup_result = deduplicate_temp_table( 

173 connection, 

174 temp_table_name, 

175 conflict_strategy.columns 

176 ) 

177 logger.info(f"Deduplication: {dedup_result.original_count} -> {dedup_result.deduplicated_count}") 

178 

179 # Step 7: Execute upsert 

180 columns_to_update = update_columns or matched_columns or list(data_list[0].keys()) 

181 inserted_count, updated_count = execute_upsert( 

182 connection, 

183 temp_table_name, 

184 target_table, 

185 conflict_strategy, 

186 columns_to_update, 

187 schema, 

188 ) 

189 logger.info(f"Upsert complete: {inserted_count} inserted, {updated_count} updated\n") 

190 

191 # Step 8: Create final result 

192 result = UpsertResult( 

193 rows_inserted=inserted_count, 

194 rows_updated=updated_count, 

195 total_affected=inserted_count + updated_count, 

196 deduplication_result=dedup_result, 

197 matched_columns=matched_columns or list(data_list[0].keys()), 

198 conflict_strategy_type=conflict_strategy.type, 

199 conflict_strategy_description=conflict_strategy.description 

200 ) 

201 

202 return result 

203 

204 finally: 

205 # Clean up temp table unless requested to keep 

206 if not keep_temp_table: 

207 try: 

208 with connection.cursor() as cursor: 

209 cursor.execute(f"DROP TABLE IF EXISTS {temp_table_name}") 

210 connection.commit() 

211 logger.debug(f"Cleaned up temp table: {temp_table_name}") 

212 except Exception as e: 

213 logger.warning(f"Failed to clean up temp table {temp_table_name}: {e}") 

214 

215 

216class UpsertEngine: 

217 """Main interface for PostgreSQL upsert operations. 

218 

219 This class provides a clean API for all upsert functionality including 

220 connection management, data loading, and workflow execution. 

221 """ 

222 

223 @staticmethod 

224 def create_connection() -> psycopg2.extensions.connection: 

225 """Create PostgreSQL connection from environment variables. 

226 

227 Expected environment variables: 

228 - PGHOST (default: localhost) 

229 - PGPORT (default: 5432) 

230 - PGDATABASE (required) 

231 - PGUSER (required) 

232 - PGPASSWORD (required) 

233 

234 Returns: 

235 psycopg2.connection: Active database connection 

236 

237 Raises: 

238 ConnectionError: If connection fails 

239 """ 

240 return create_connection_from_env() 

241 

242 @staticmethod 

243 def test_connection() -> psycopg2.extensions.connection: 

244 """Test database connection and validate permissions for upsert operations. 

245 

246 This method creates a connection and validates that the user has 

247 sufficient permissions to perform upsert operations (CREATE TEMP TABLE). 

248 

249 Returns: 

250 psycopg2.connection: Validated connection ready for use 

251 

252 Raises: 

253 ConnectionError: If connection fails 

254 PermissionError: If user lacks required permissions 

255 """ 

256 return test_connection() 

257 

258 @staticmethod 

259 def upsert_data( 

260 connection: psycopg2.extensions.connection, 

261 data: list[dict[str, str]] | str | Path, 

262 target_table: str, 

263 conflict_columns: list[str] | None = None, 

264 update_columns: list[str] | None = None, 

265 batch_size: int = 1000, 

266 keep_temp_table: bool = False, 

267 schema: str = 'public' 

268 ) -> UpsertResult: 

269 """Execute complete upsert workflow with automatic conflict detection. 

270 

271 This function orchestrates the complete upsert process including: 

272 1. Data input handling (CSV files or direct data lists) 

273 2. Temporary table creation with automatic column detection 

274 3. Conflict strategy detection (primary key, unique constraints, or insert-only) 

275 4. Data deduplication using appropriate strategy 

276 5. Upsert execution with proper conflict resolution 

277 

278 Args: 

279 connection: Active PostgreSQL database connection 

280 target_table: Name of the target table for upsert operation 

281 data: Input data as list of dictionaries, CSV file path, or Path object 

282 conflict_columns: Optional override for conflict detection columns. If provided, 

283 these columns will be used for conflict resolution instead of 

284 automatic detection (primary keys, unique constraints) 

285 update_columns: Optional override for columns to update on conflict. If not 

286 provided, all matched columns will be updated 

287 batch_size: Number of rows to process in each batch during temp table population 

288 temp_table_prefix: Prefix for temporary table name (default: "_temp_") 

289 keep_temp_table: Whether to preserve temporary table after operation 

290 

291 Returns: 

292 UpsertResult: Object containing operation results and statistics 

293 

294 Raises: 

295 ValueError: If data is empty or target_table is invalid 

296 psycopg2.Error: For database connection or operation errors 

297 

298 Example: 

299 >>> # Using direct data 

300 >>> data = [{'id': 1, 'name': 'John'}, {'id': 2, 'name': 'Jane'}] 

301 >>> result = execute_upsert_workflow(conn, 'users', data) 

302 >>> print(f"Inserted: {result.inserted_count}, Updated: {result.updated_count}") 

303 

304 >>> # Using CSV file 

305 >>> result = execute_upsert_workflow(conn, 'users', 'data.csv') 

306 

307 Note: 

308 For serverless ETL: This function is designed to work seamlessly with 

309 API responses by accepting direct data lists, eliminating the need 

310 for intermediate CSV file creation in lambda/cloud functions. 

311 """ 

312 

313 return execute_upsert_workflow( 

314 connection=connection, 

315 data=data, 

316 target_table=target_table, 

317 conflict_columns=conflict_columns, 

318 update_columns=update_columns, 

319 batch_size=batch_size, 

320 keep_temp_table=keep_temp_table, 

321 schema=schema 

322 )