11import re
2- from django .db .models import Q
2+ from django .db .models import BooleanField , Q
3+ from django .db .models .expressions import RawSQL
34from ._types import _JsonDict
45from .base import BaseImporter
56from ..data .models import Organization
67
8+ # Matches any "&" surrounded by optional whitespace, so it can be expanded
9+ # to " and " before punctuation is stripped (otherwise "Fish & Game" and
10+ # "Fish and Game" would normalize to different strings: "FISH GAME" vs
11+ # "FISH AND GAME").
12+ _AMPERSAND_RE = re .compile (r"\s*&\s*" )
13+
14+ # Matches everything except letters, digits, and whitespace - used to
15+ # compare organization names without regard to punctuation (commas,
16+ # apostrophes, periods, etc). Keeping the Python and SQL normalization in
17+ # sync is important: both sides of the comparison must use the same rule.
18+ _NON_ALPHANUMERIC_RE = re .compile (r"[^A-Za-z0-9 ]" )
19+
20+ # Collapses runs of whitespace down to a single space (ampersand expansion
21+ # above can introduce extra spaces, e.g. "A&B" -> "A and B").
22+ _EXTRA_WHITESPACE_RE = re .compile (r"\s+" )
23+
24+ # The equivalent normalization expressed as a chain of Postgres
25+ # regexp_replace calls, so we can apply it to `name` and to each entry of
26+ # the `other_names` JSONB array. `{column}` is substituted with the SQL
27+ # expression for the column/value being normalized.
28+ _NORMALIZE_SQL_TEMPLATE = """
29+ upper(trim(regexp_replace(
30+ regexp_replace(
31+ regexp_replace({column}, '\\ s*&\\ s*', ' and ', 'g'),
32+ '[^A-Za-z0-9 ]', '', 'g'
33+ ),
34+ '\\ s+', ' ', 'g'
35+ )))
36+ """
37+
38+ # Raw SQL boolean expression used to find organizations whose `name` OR any
39+ # entry in the `other_names` JSONB array matches a normalized name. This
40+ # avoids trying to guess the exact case/punctuation used in `other_names`
41+ # (which is free-form, human-entered data from the people repo) - instead
42+ # both sides are normalized (uppercased, punctuation stripped, "&"
43+ # expanded to "and") before comparison.
44+ _NORMALIZED_NAME_MATCH_SQL = f"""
45+ { _NORMALIZE_SQL_TEMPLATE .format (column = "name" )} = %s
46+ OR EXISTS (
47+ SELECT 1 FROM jsonb_array_elements(other_names) AS other_name
48+ WHERE { _NORMALIZE_SQL_TEMPLATE .format (column = "other_name ->> 'name'" )} = %s
49+ )
50+ """
51+
52+
53+ def _normalize_name (name : str ) -> str :
54+ """Normalize an organization name for comparison purposes: expand "&"
55+ to "and", strip punctuation, collapse whitespace, and uppercase. This
56+ must stay equivalent to the Postgres expression in
57+ _NORMALIZE_SQL_TEMPLATE."""
58+ name = _AMPERSAND_RE .sub (" and " , name )
59+ name = _NON_ALPHANUMERIC_RE .sub ("" , name )
60+ name = _EXTRA_WHITESPACE_RE .sub (" " , name )
61+ return name .strip ().upper ()
62+
763
864class OrganizationImporter (BaseImporter ):
965 _type = "organization"
@@ -13,29 +69,27 @@ def limit_spec(self, spec: _JsonDict) -> _JsonDict:
1369 if spec .get ("classification" ) != "party" :
1470 spec ["jurisdiction_id" ] = self .jurisdiction_id
1571
16- org_name_prepositions = ["and" , "at" , "by" , "for" , "in" , "on" , "of" , "the" ]
1772 name = spec .pop ("name" , None )
1873 # if chamber is included in pseudo_person_id, we assume this is a committee
1974 # and chamber is here to help us find its parent
2075 chamber_classification = spec .pop ("chamber" , None )
2176 if name :
22- # __icontains doesn't work for JSONField ArrayField
23- # so name follows "title" naming pattern
24- name = name .title ()
25- pattern = "(" + "|" .join (org_name_prepositions ) + ")"
26- name = re .sub (
27- pattern , lambda match : match .group (0 ).lower (), name , flags = re .IGNORECASE
28- )
29- name = name .replace (" & " , " and " )
77+ normalized_name = _normalize_name (name )
78+ matching_orgs = Organization .objects .annotate (
79+ _normalized_name_match = RawSQL (
80+ _NORMALIZED_NAME_MATCH_SQL ,
81+ (normalized_name , normalized_name ),
82+ output_field = BooleanField (),
83+ )
84+ ).filter (_normalized_name_match = True )
85+ name_q = Q (pk__in = matching_orgs )
3086
3187 if chamber_classification :
3288 return (
3389 Q (** spec )
34- & ( Q ( name__iexact = name ) | Q ( other_names__contains = [{ "name" : name }]))
90+ & name_q
3591 & Q (parent__classification = chamber_classification )
3692 )
3793 else :
38- return Q (** spec ) & (
39- Q (name__iexact = name ) | Q (other_names__contains = [{"name" : name }])
40- )
94+ return Q (** spec ) & name_q
4195 return spec
0 commit comments