fix(tradein/cian): management_companies dedup — SELECT-before-INSERT, not ON CONFLICT
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 8s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 51s
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 8s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 51s
ext_id=NULL rows never conflict under Postgres default NULLS DISTINCT, so the ON CONFLICT (ext_source, name, ext_id) DO UPDATE in _upsert_cian_valuation_management_company was dead code — every valuation cache-miss for the same building inserted a fresh duplicate management_companies row instead of deduping by name. Replaced with an explicit SELECT-then-branch (UPDATE existing / INSERT new); docstring corrected to stop claiming the constraint dedups it. Found by deep-code-reviewer on #2435. Refs #2435
This commit is contained in:
parent
37ea0b0f5b
commit
db9bec8142
2 changed files with 95 additions and 16 deletions
|
|
@ -225,8 +225,16 @@ def _cian_result(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _mock_db_valuation(mc_insert_id: int | None = 900) -> MagicMock:
|
def _mock_db_valuation(
|
||||||
"""Mock db для _save_to_cache: INSERT management_companies RETURNING id + UPDATE houses."""
|
mc_insert_id: int | None = 900, mc_existing_id: int | None = None
|
||||||
|
) -> MagicMock:
|
||||||
|
"""Mock db для _save_to_cache: SELECT (dedup) → UPDATE-or-INSERT management_companies,
|
||||||
|
затем UPDATE houses.
|
||||||
|
|
||||||
|
`mc_existing_id` simulates the dedup SELECT finding a prior row (existing.fetchone()
|
||||||
|
returns that id) — the UPDATE branch is taken instead of INSERT. Default None means
|
||||||
|
"no existing row", exercising the INSERT branch (the original test-suite behavior).
|
||||||
|
"""
|
||||||
db = MagicMock()
|
db = MagicMock()
|
||||||
|
|
||||||
@contextmanager
|
@contextmanager
|
||||||
|
|
@ -238,7 +246,11 @@ def _mock_db_valuation(mc_insert_id: int | None = 900) -> MagicMock:
|
||||||
def _exec(sql, params=None):
|
def _exec(sql, params=None):
|
||||||
sql_str = str(sql)
|
sql_str = str(sql)
|
||||||
mock_result = MagicMock()
|
mock_result = MagicMock()
|
||||||
if "INSERT INTO management_companies" in sql_str:
|
if "SELECT id FROM management_companies" in sql_str:
|
||||||
|
mock_result.fetchone.return_value = (
|
||||||
|
(mc_existing_id,) if mc_existing_id is not None else None
|
||||||
|
)
|
||||||
|
elif "INSERT INTO management_companies" in sql_str:
|
||||||
mock_result.fetchone.return_value = (
|
mock_result.fetchone.return_value = (
|
||||||
(mc_insert_id,) if mc_insert_id is not None else None
|
(mc_insert_id,) if mc_insert_id is not None else None
|
||||||
)
|
)
|
||||||
|
|
@ -250,6 +262,22 @@ def _mock_db_valuation(mc_insert_id: int | None = 900) -> MagicMock:
|
||||||
return db
|
return db
|
||||||
|
|
||||||
|
|
||||||
|
def _mc_calls(db: MagicMock) -> list[str]:
|
||||||
|
"""SQL statement kinds touching management_companies, in call order."""
|
||||||
|
kinds = []
|
||||||
|
for call_args in db.execute.call_args_list:
|
||||||
|
if not call_args.args:
|
||||||
|
continue
|
||||||
|
sql = str(call_args.args[0])
|
||||||
|
if "SELECT id FROM management_companies" in sql:
|
||||||
|
kinds.append("select")
|
||||||
|
elif "INSERT INTO management_companies" in sql:
|
||||||
|
kinds.append("insert")
|
||||||
|
elif "UPDATE management_companies" in sql:
|
||||||
|
kinds.append("update")
|
||||||
|
return kinds
|
||||||
|
|
||||||
|
|
||||||
def _houses_update_call(db: MagicMock) -> tuple[str, dict] | None:
|
def _houses_update_call(db: MagicMock) -> tuple[str, dict] | None:
|
||||||
for call_args in db.execute.call_args_list:
|
for call_args in db.execute.call_args_list:
|
||||||
if not call_args.args:
|
if not call_args.args:
|
||||||
|
|
@ -278,7 +306,8 @@ def _call_save_to_cache(db: MagicMock, result: CianValuationResult, house_id: in
|
||||||
|
|
||||||
|
|
||||||
def test_valuation_house_info_and_management_company_land_in_houses_update():
|
def test_valuation_house_info_and_management_company_land_in_houses_update():
|
||||||
"""house_id задан → UPDATE houses с management_company_id + house_info-полями."""
|
"""house_id задан, УК ранее не встречалась → SELECT (не найдено) → INSERT management_companies,
|
||||||
|
UPDATE houses с management_company_id + house_info-полями."""
|
||||||
db = _mock_db_valuation(mc_insert_id=900)
|
db = _mock_db_valuation(mc_insert_id=900)
|
||||||
result = _cian_result(
|
result = _cian_result(
|
||||||
house_info=_HOUSE_INFO_SAMPLE,
|
house_info=_HOUSE_INFO_SAMPLE,
|
||||||
|
|
@ -293,6 +322,7 @@ def test_valuation_house_info_and_management_company_land_in_houses_update():
|
||||||
|
|
||||||
_call_save_to_cache(db, result, house_id=42)
|
_call_save_to_cache(db, result, house_id=42)
|
||||||
|
|
||||||
|
assert _mc_calls(db) == ["select", "insert"]
|
||||||
call = _houses_update_call(db)
|
call = _houses_update_call(db)
|
||||||
assert call is not None
|
assert call is not None
|
||||||
sql, params = call
|
sql, params = call
|
||||||
|
|
@ -315,6 +345,29 @@ def test_valuation_house_info_and_management_company_land_in_houses_update():
|
||||||
db.commit.assert_called_once()
|
db.commit.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
def test_valuation_management_company_dedups_on_repeat_call_same_name():
|
||||||
|
"""Regression for the NULLS-DISTINCT dedup bug (#2435 review): a repeat valuation
|
||||||
|
call for a management company already persisted (same ext_source/name, ext_id=NULL)
|
||||||
|
must find it via the explicit SELECT and UPDATE it — not INSERT a duplicate row.
|
||||||
|
`ON CONFLICT (ext_source, name, ext_id)` alone can't dedup ext_id=NULL rows under
|
||||||
|
Postgres's default NULLS DISTINCT (two NULLs never compare equal), which is why
|
||||||
|
dedup is done via SELECT-before-INSERT rather than relying on the constraint."""
|
||||||
|
db = _mock_db_valuation(mc_existing_id=900)
|
||||||
|
result = _cian_result(
|
||||||
|
house_info=[],
|
||||||
|
management_company={"name": 'ТСЖ "Учителей, 18"', "phones": ["+7 (343) 216-59-12"]},
|
||||||
|
)
|
||||||
|
|
||||||
|
_call_save_to_cache(db, result, house_id=42)
|
||||||
|
|
||||||
|
assert _mc_calls(db) == ["select", "update"]
|
||||||
|
call = _houses_update_call(db)
|
||||||
|
assert call is not None
|
||||||
|
_, params = call
|
||||||
|
assert params["mcid"] == 900
|
||||||
|
db.commit.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
def test_valuation_house_id_none_skips_house_update():
|
def test_valuation_house_id_none_skips_house_update():
|
||||||
"""house_id=None → нет UPDATE houses (best-effort no-op, тот же no-crash контракт,
|
"""house_id=None → нет UPDATE houses (best-effort no-op, тот же no-crash контракт,
|
||||||
что test_extval_house_id_write_path.py::test_cian_save_null_house_id_no_exception)."""
|
что test_extval_house_id_write_path.py::test_cian_save_null_house_id_no_exception)."""
|
||||||
|
|
|
||||||
|
|
@ -656,11 +656,14 @@ def _upsert_cian_valuation_management_company(
|
||||||
) -> int | None:
|
) -> int | None:
|
||||||
"""UPSERT management_companies из Cian Valuation Calculator's managementCompany.data.
|
"""UPSERT management_companies из Cian Valuation Calculator's managementCompany.data.
|
||||||
|
|
||||||
Тот же UPSERT-паттерн, что providers/cian/newbuilding.py::save_newbuilding_enrichment
|
Тот же намерение, что providers/cian/newbuilding.py::save_newbuilding_enrichment
|
||||||
(независимая копия — newbuilding.py вне scope #2435, не рефакторим/не переиспользуем).
|
(независимая копия — newbuilding.py вне scope #2435, не рефакторим/не переиспользуем),
|
||||||
ext_source='cian_valuation' (не 'cian') — валюация не даёт числового id УК (в отличие
|
НО другой механизм dedup. ext_source='cian_valuation' (не 'cian') — валюация не даёт
|
||||||
от newbuilding.managementCompany.id), поэтому dedup-ключ (ext_source, name, ext_id=NULL)
|
числового id УК (в отличие от newbuilding.managementCompany.id), поэтому пишем
|
||||||
сходится по имени УК среди повторных valuation-запросов.
|
ext_id=NULL. `UNIQUE (ext_source, name, ext_id)` под Postgres-дефолтом NULLS DISTINCT
|
||||||
|
не считает два NULL равными — `ON CONFLICT` с ext_id=NULL никогда бы не сработал (каждый
|
||||||
|
вызов создавал бы новую дублирующую строку). Поэтому dedup сделан явно: SELECT по
|
||||||
|
(ext_source, name) WHERE ext_id IS NULL перед INSERT, а не через constraint.
|
||||||
|
|
||||||
opening_hours — Cian отдаёт список словарей ([{"Пн–Пт": ["10:00-20:00"]}, ...]), не
|
opening_hours — Cian отдаёт список словарей ([{"Пн–Пт": ["10:00-20:00"]}, ...]), не
|
||||||
строку; json.dumps в text-колонку (в отличие от newbuilding.py, которая пишет объект
|
строку; json.dumps в text-колонку (в отличие от newbuilding.py, которая пишет объект
|
||||||
|
|
@ -673,6 +676,32 @@ def _upsert_cian_valuation_management_company(
|
||||||
return None
|
return None
|
||||||
|
|
||||||
opening_hours = mc.get("openingHours")
|
opening_hours = mc.get("openingHours")
|
||||||
|
phones = mc.get("phones") or []
|
||||||
|
email = mc.get("email")
|
||||||
|
oh_json = json.dumps(opening_hours, ensure_ascii=False) if opening_hours else None
|
||||||
|
chief = mc.get("chiefName")
|
||||||
|
|
||||||
|
existing = db.execute(
|
||||||
|
text("""
|
||||||
|
SELECT id FROM management_companies
|
||||||
|
WHERE ext_source = 'cian_valuation' AND name = :name AND ext_id IS NULL
|
||||||
|
LIMIT 1
|
||||||
|
"""),
|
||||||
|
{"name": name},
|
||||||
|
).fetchone()
|
||||||
|
if existing:
|
||||||
|
mc_id = int(existing[0])
|
||||||
|
db.execute(
|
||||||
|
text("""
|
||||||
|
UPDATE management_companies SET
|
||||||
|
phones = COALESCE(CAST(:phones AS text[]), phones),
|
||||||
|
email = COALESCE(:email, email)
|
||||||
|
WHERE id = CAST(:mc_id AS bigint)
|
||||||
|
"""),
|
||||||
|
{"phones": phones, "email": email, "mc_id": mc_id},
|
||||||
|
)
|
||||||
|
return mc_id
|
||||||
|
|
||||||
row = db.execute(
|
row = db.execute(
|
||||||
text("""
|
text("""
|
||||||
INSERT INTO management_companies (
|
INSERT INTO management_companies (
|
||||||
|
|
@ -680,17 +709,14 @@ def _upsert_cian_valuation_management_company(
|
||||||
) VALUES (
|
) VALUES (
|
||||||
:name, CAST(:phones AS text[]), :email, :oh, :chief, 'cian_valuation', NULL
|
:name, CAST(:phones AS text[]), :email, :oh, :chief, 'cian_valuation', NULL
|
||||||
)
|
)
|
||||||
ON CONFLICT (ext_source, name, ext_id) DO UPDATE SET
|
|
||||||
phones = COALESCE(EXCLUDED.phones, management_companies.phones),
|
|
||||||
email = COALESCE(EXCLUDED.email, management_companies.email)
|
|
||||||
RETURNING id
|
RETURNING id
|
||||||
"""),
|
"""),
|
||||||
{
|
{
|
||||||
"name": name,
|
"name": name,
|
||||||
"phones": mc.get("phones") or [],
|
"phones": phones,
|
||||||
"email": mc.get("email"),
|
"email": email,
|
||||||
"oh": json.dumps(opening_hours, ensure_ascii=False) if opening_hours else None,
|
"oh": oh_json,
|
||||||
"chief": mc.get("chiefName"),
|
"chief": chief,
|
||||||
},
|
},
|
||||||
).fetchone()
|
).fetchone()
|
||||||
return int(row[0]) if row else None
|
return int(row[0]) if row else None
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue