|
7 | 7 | from dateutil import parser
|
8 | 8 | from django.db import models
|
9 | 9 |
|
| 10 | +from sentry.backup.dependencies import PrimaryKeyMap, dependencies |
10 | 11 | from sentry.backup.findings import ComparatorFinding, ComparatorFindingKind, InstanceID
|
11 | 12 | from sentry.backup.helpers import Side, get_exportable_final_derivations_of
|
12 | 13 | from sentry.db.models import BaseModel
|
@@ -200,6 +201,73 @@ def compare(self, on: InstanceID, left: JSONData, right: JSONData) -> list[Compa
|
200 | 201 | return findings
|
201 | 202 |
|
202 | 203 |
|
| 204 | +class ForeignKeyComparator(JSONScrubbingComparator): |
| 205 | + """Ensures that foreign keys match in a relative (they refer to the same other model in their |
| 206 | + respective JSON blobs) rather than absolute (they have literally the same integer value) |
| 207 | + sense.""" |
| 208 | + |
| 209 | + left_pk_map: PrimaryKeyMap | None = None |
| 210 | + right_pk_map: PrimaryKeyMap | None = None |
| 211 | + |
| 212 | + def __init__(self, foreign_fields: dict[str, models.base.ModelBase]): |
| 213 | + super().__init__(*(foreign_fields.keys())) |
| 214 | + self.foreign_fields = foreign_fields |
| 215 | + |
| 216 | + def set_primary_key_maps(self, left_pk_map: PrimaryKeyMap, right_pk_map: PrimaryKeyMap): |
| 217 | + """Call this function before running the comparator, to ensure that it has access to the latest mapping information for both sides of the comparison.""" |
| 218 | + |
| 219 | + self.left_pk_map = left_pk_map |
| 220 | + self.right_pk_map = right_pk_map |
| 221 | + |
| 222 | + def compare(self, on: InstanceID, left: JSONData, right: JSONData) -> list[ComparatorFinding]: |
| 223 | + findings = [] |
| 224 | + fields = sorted(self.fields) |
| 225 | + for f in fields: |
| 226 | + field_model_name = "sentry." + self.foreign_fields[f].__name__.lower() |
| 227 | + if left["fields"].get(f) is None and right["fields"].get(f) is None: |
| 228 | + continue |
| 229 | + |
| 230 | + if self.left_pk_map is None or self.right_pk_map is None: |
| 231 | + raise RuntimeError("must call `set_primary_key_maps` before comparing") |
| 232 | + |
| 233 | + left_fk_as_ordinal = self.left_pk_map.get(field_model_name, left["fields"][f]) |
| 234 | + right_fk_as_ordinal = self.right_pk_map.get(field_model_name, right["fields"][f]) |
| 235 | + if left_fk_as_ordinal is None or right_fk_as_ordinal is None: |
| 236 | + if left_fk_as_ordinal is None: |
| 237 | + findings.append( |
| 238 | + ComparatorFinding( |
| 239 | + kind=self.get_kind(), |
| 240 | + on=on, |
| 241 | + left_pk=left["pk"], |
| 242 | + right_pk=right["pk"], |
| 243 | + reason=f"""the left foreign key ordinal for `{f}` model with pk `{left["fields"][f]}` could not be found""", |
| 244 | + ) |
| 245 | + ) |
| 246 | + if right_fk_as_ordinal is None: |
| 247 | + findings.append( |
| 248 | + ComparatorFinding( |
| 249 | + kind=self.get_kind(), |
| 250 | + on=on, |
| 251 | + left_pk=left["pk"], |
| 252 | + right_pk=right["pk"], |
| 253 | + reason=f"""the right foreign key ordinal for `{f}` model with pk `{right["fields"][f]}` could not be found""", |
| 254 | + ) |
| 255 | + ) |
| 256 | + continue |
| 257 | + |
| 258 | + if left_fk_as_ordinal != right_fk_as_ordinal: |
| 259 | + findings.append( |
| 260 | + ComparatorFinding( |
| 261 | + kind=self.get_kind(), |
| 262 | + on=on, |
| 263 | + left_pk=left["pk"], |
| 264 | + right_pk=right["pk"], |
| 265 | + reason=f"""the left foreign key ordinal ({left_fk_as_ordinal}) for `{f}` was not equal to the right foreign key ordinal ({right_fk_as_ordinal})""", |
| 266 | + ) |
| 267 | + ) |
| 268 | + return findings |
| 269 | + |
| 270 | + |
203 | 271 | class ObfuscatingComparator(JSONScrubbingComparator, ABC):
|
204 | 272 | """Comparator that compares private values, but then safely truncates them to ensure that they
|
205 | 273 | do not leak out in logs, stack traces, etc."""
|
@@ -338,6 +406,16 @@ def auto_assign_email_obfuscating_comparators(comps: ComparatorMap) -> None:
|
338 | 406 | comps[name].append(EmailObfuscatingComparator(*assign))
|
339 | 407 |
|
340 | 408 |
|
| 409 | +def auto_assign_foreign_key_comparators(comps: ComparatorMap) -> None: |
| 410 | + """Automatically assigns the ForeignKeyComparator or to all appropriate model fields (see |
| 411 | + dependencies.py for more on what "appropriate" means in this context).""" |
| 412 | + |
| 413 | + for model_name, rels in dependencies().items(): |
| 414 | + comps[model_name.lower()].append( |
| 415 | + ForeignKeyComparator({k: v.model for k, v in rels.foreign_keys.items()}) |
| 416 | + ) |
| 417 | + |
| 418 | + |
341 | 419 | ComparatorList = List[JSONScrubbingComparator]
|
342 | 420 | ComparatorMap = Dict[str, ComparatorList]
|
343 | 421 |
|
@@ -380,6 +458,7 @@ def build_default_comparators():
|
380 | 458 | # to the `DEFAULT_COMPARATORS` map.
|
381 | 459 | auto_assign_datetime_equality_comparators(comparators)
|
382 | 460 | auto_assign_email_obfuscating_comparators(comparators)
|
| 461 | + auto_assign_foreign_key_comparators(comparators) |
383 | 462 |
|
384 | 463 | return comparators
|
385 | 464 |
|
|
0 commit comments