Coverage for pgsql_upserter / conflict_resolver.py: 80.88%
112 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-03-26 18:15 -0300
« prev ^ index » next coverage.py v7.13.5, created at 2026-03-26 18:15 -0300
1"""Conflict resolution logic for PostgreSQL upsert operations."""
3import logging
5from dataclasses import dataclass
7from .schema_inspector import TableSchema
8from .exceptions import PgsqlUpserterError
10# Configure module logger
11logger = logging.getLogger(__name__)
14@dataclass
15class ConflictStrategy:
16 """Represents a conflict resolution strategy."""
17 type: str # "PRIMARY_KEY", "UNIQUE_COMBINED", "INSERT_ONLY"
18 columns: list[str] # Column names for conflict resolution
19 description: str # Human-readable description
22@dataclass
23class DeduplicationResult:
24 """Results from temp table deduplication process."""
25 original_count: int
26 deduplicated_count: int
27 dropped_count: int
28 dropped_reasons: dict[str, int] # reason -> count mapping
31def find_conflict_strategy(
32 table_schema: TableSchema,
33 matched_columns: list[str]
34) -> ConflictStrategy:
35 """
36 Determine the best conflict resolution strategy based on table schema and available columns.
38 Priority:
39 1. Non-auto-generated primary key(s) - highest priority
40 2. All unique constraints combined - medium priority
41 3. Simple insert only - fallback
43 Args:
44 table_schema: Table schema information
45 matched_columns: List of columns available in the data (from column_matcher)
47 Returns:
48 ConflictStrategy object with strategy details
50 Raises:
51 PgsqlUpserterError: If no valid strategy can be determined
52 """
53 logger.debug(f"Analyzing conflict strategy for table '{table_schema.table_name}' "
54 f"with {len(matched_columns)} available columns")
56 # Convert matched_columns to set for faster lookup
57 available_columns = set(matched_columns)
59 # Strategy 1: Check for non-auto-generated primary key
60 if table_schema.primary_key: 60 ↛ 78line 60 didn't jump to line 78 because the condition on line 60 was always true
61 pk_columns = table_schema.primary_key.columns
62 # Check if all PK columns are available in matched_columns
63 pk_available = all(col in available_columns for col in pk_columns)
65 if pk_available: 65 ↛ 66line 65 didn't jump to line 66 because the condition on line 65 was never true
66 strategy = ConflictStrategy(
67 type="PRIMARY_KEY",
68 columns=pk_columns,
69 description=f"Primary key conflict resolution on: {pk_columns}"
70 )
71 logger.debug(f"Using PRIMARY_KEY strategy: {strategy.description}")
72 return strategy
73 else:
74 missing_pk_cols = [col for col in pk_columns if col not in available_columns]
75 logger.warning(f"Primary key columns not available in data: {missing_pk_cols}")
77 # Strategy 2: Combine all unique constraints (union approach)
78 all_unique_columns = []
79 for constraint in table_schema.unique_constraints:
80 # Only include constraint columns that are available in matched_columns
81 available_constraint_cols = [col for col in constraint.columns if col in available_columns]
82 all_unique_columns.extend(available_constraint_cols)
84 # Remove duplicates
85 unique_columns = list(set(all_unique_columns))
87 if unique_columns: 87 ↛ 97line 87 didn't jump to line 97 because the condition on line 87 was always true
88 strategy = ConflictStrategy(
89 type="UNIQUE_COMBINED",
90 columns=unique_columns,
91 description=f"Combined unique constraints conflict resolution on: {unique_columns}"
92 )
93 logger.debug(f"Using UNIQUE_COMBINED strategy: {strategy.description}")
94 return strategy
96 # Strategy 3: Fallback to INSERT only
97 strategy = ConflictStrategy(
98 type="INSERT_ONLY",
99 columns=[],
100 description="No conflict resolution - INSERT only (no unique constraints found)"
101 )
102 logger.warning(f"Fallback to INSERT_ONLY strategy: {strategy.description}")
103 return strategy
106def deduplicate_temp_table(
107 connection,
108 temp_table_name: str,
109 conflict_columns: list[str]
110) -> DeduplicationResult:
111 """
112 Deduplicate temp table based on conflict columns, keeping last occurrence.
113 Creates a new cleaned temp table and logs dropped rows.
115 Args:
116 connection: Database connection
117 temp_table_name: Name of the source temp table
118 conflict_columns: Columns to use for deduplication
119 schema_name: Schema name (default: 'public')
121 Returns:
122 DeduplicationResult with statistics
124 Raises:
125 PgsqlUpserterError: If deduplication fails
126 """
127 logger.debug(f"Starting deduplication of '{temp_table_name}' on columns: {conflict_columns}")
129 try:
130 with connection.cursor() as cursor:
131 # Get original count
132 cursor.execute(f"SELECT COUNT(*) FROM {temp_table_name}")
133 original_count = cursor.fetchone()[0]
134 logger.debug(f"Original temp table count: {original_count} rows")
136 if not conflict_columns: 136 ↛ 138line 136 didn't jump to line 138 because the condition on line 136 was never true
137 # No deduplication needed for INSERT_ONLY strategy
138 return DeduplicationResult(
139 original_count=original_count,
140 deduplicated_count=original_count,
141 dropped_count=0,
142 dropped_reasons={}
143 )
145 # Create cleaned temp table name
146 cleaned_table_name = f"{temp_table_name}_cleaned"
148 # Step 1: Build NULL checking conditions for conflict columns
149 # We need to handle text columns differently from other types
150 cursor.execute(f"""
151 SELECT column_name, data_type
152 FROM information_schema.columns
153 WHERE table_name = '{temp_table_name.split('.')[-1]}'
154 AND column_name = ANY(%s)
155 """, (conflict_columns,))
157 column_types = {row[0]: row[1] for row in cursor.fetchall()}
159 null_conditions = []
160 for col in conflict_columns:
161 if col in column_types: 161 ↛ 171line 161 didn't jump to line 171 because the condition on line 161 was always true
162 data_type = column_types[col]
163 if data_type in ('text', 'varchar', 'character varying', 'char'):
164 # For text columns, check both NULL and empty string
165 null_conditions.append(f"({col} IS NULL OR {col} = '')")
166 else:
167 # For other types (date, numeric, etc.), only check NULL
168 null_conditions.append(f"{col} IS NULL")
169 else:
170 # Fallback: assume text type
171 null_conditions.append(f"({col} IS NULL OR {col} = '')")
173 null_where_clause = " OR ".join(null_conditions)
175 # Count rows with NULLs before removing
176 cursor.execute(f"""
177 SELECT COUNT(*)
178 FROM {temp_table_name}
179 WHERE {null_where_clause}
180 """)
181 null_count = cursor.fetchone()[0]
183 # Step 2: Create cleaned table with deduplication (keeping last occurrence)
184 conflict_columns_str = ", ".join(conflict_columns)
186 cursor.execute(f"""
187 CREATE TEMP TABLE {cleaned_table_name} AS
188 SELECT * FROM (
189 SELECT *,
190 ROW_NUMBER() OVER (
191 PARTITION BY {conflict_columns_str}
192 ORDER BY ctid DESC
193 ) as rn
194 FROM {temp_table_name}
195 WHERE NOT ({null_where_clause})
196 ) ranked
197 WHERE rn = 1
198 """)
200 # Get final count
201 cursor.execute(f"SELECT COUNT(*) FROM {cleaned_table_name}")
202 deduplicated_count = cursor.fetchone()[0]
204 # Calculate dropped counts
205 duplicate_count = (original_count - null_count) - deduplicated_count
206 total_dropped = original_count - deduplicated_count
208 dropped_reasons = {}
209 if null_count > 0: 209 ↛ 210line 209 didn't jump to line 210 because the condition on line 209 was never true
210 dropped_reasons["null_or_empty_conflict_columns"] = null_count
211 if duplicate_count > 0:
212 dropped_reasons["duplicate_conflict_keys"] = duplicate_count
214 # Drop original temp table and rename cleaned table
215 cursor.execute(f"DROP TABLE {temp_table_name}")
216 cursor.execute(f"ALTER TABLE {cleaned_table_name} DROP COLUMN rn")
217 cursor.execute(f"ALTER TABLE {cleaned_table_name} RENAME TO {temp_table_name.split('.')[-1]}")
219 result = DeduplicationResult(
220 original_count=original_count,
221 deduplicated_count=deduplicated_count,
222 dropped_count=total_dropped,
223 dropped_reasons=dropped_reasons
224 )
226 logger.debug(f"Deduplication completed: {original_count} → {deduplicated_count} rows "
227 f"({total_dropped} dropped)")
228 for reason, count in dropped_reasons.items():
229 logger.debug(f" - {reason}: {count} rows")
231 return result
233 except Exception as e:
234 logger.error(f"Deduplication failed: {e}")
235 raise PgsqlUpserterError(f"Failed to deduplicate temp table: {e}") from e
238def execute_upsert(
239 connection,
240 temp_table_name: str,
241 target_table: str,
242 conflict_strategy: ConflictStrategy,
243 matched_columns: list[str],
244 schema_name: str = 'public'
245) -> tuple[int, int]:
246 """
247 Execute the final upsert operation using INSERT...ON CONFLICT.
249 Args:
250 connection: Database connection
251 temp_table_name: Name of the cleaned temp table
252 target_table: Target table name
253 conflict_strategy: Conflict resolution strategy
254 matched_columns: List of columns available in the temp table
255 schema_name: Schema name (default: 'public')
257 Returns:
258 Tuple of (rows_inserted, rows_updated)
260 Raises:
261 PgsqlUpserterError: If upsert operation fails
262 """
263 logger.debug(f"Executing upsert from '{temp_table_name}' to '{schema_name}.{target_table}' "
264 f"using {conflict_strategy.type} strategy")
266 try:
267 with connection.cursor() as cursor:
268 # Use matched_columns instead of querying target table columns
269 columns_str = ", ".join(matched_columns)
271 if conflict_strategy.type == "INSERT_ONLY": 271 ↛ 273line 271 didn't jump to line 273 because the condition on line 271 was never true
272 # Simple INSERT without conflict resolution
273 cursor.execute(f"""
274 INSERT INTO {schema_name}.{target_table} ({columns_str})
275 SELECT {columns_str}
276 FROM {temp_table_name}
277 """)
279 rows_affected = cursor.rowcount
280 logger.debug(f"INSERT_ONLY completed: {rows_affected} rows inserted")
281 return rows_affected, 0
283 else:
284 # INSERT...ON CONFLICT with UPDATE
285 conflict_columns_str = ", ".join(conflict_strategy.columns)
287 # Build UPDATE SET clause (matched columns except conflict columns)
288 update_columns = [col for col in matched_columns if col not in conflict_strategy.columns]
289 update_set_clause = ", ".join([f"{col} = EXCLUDED.{col}" for col in update_columns])
291 # Track before counts for calculating inserts vs updates
292 cursor.execute(f"SELECT COUNT(*) FROM {schema_name}.{target_table}")
293 before_count = cursor.fetchone()[0]
295 cursor.execute(f"""
296 INSERT INTO {schema_name}.{target_table} ({columns_str})
297 SELECT {columns_str}
298 FROM {temp_table_name}
299 ON CONFLICT ({conflict_columns_str})
300 DO UPDATE SET {update_set_clause}
301 """)
303 # Calculate insert vs update counts
304 cursor.execute(f"SELECT COUNT(*) FROM {schema_name}.{target_table}")
305 after_count = cursor.fetchone()[0]
307 cursor.execute(f"SELECT COUNT(*) FROM {temp_table_name}")
308 temp_count = cursor.fetchone()[0]
310 rows_inserted = after_count - before_count
311 rows_updated = temp_count - rows_inserted
313 logger.debug(f"Upsert completed: {rows_inserted} inserted, {rows_updated} updated")
314 return rows_inserted, rows_updated
316 except Exception as e:
317 logger.error(f"Upsert operation failed: {e}")
318 raise PgsqlUpserterError(f"Failed to execute upsert: {e}") from e