diff --git a/docs/INTERFACE_PATTERN_MIGRATION.md b/docs/INTERFACE_PATTERN_MIGRATION.md index b3a6aef..8b78977 100644 --- a/docs/INTERFACE_PATTERN_MIGRATION.md +++ b/docs/INTERFACE_PATTERN_MIGRATION.md @@ -28,6 +28,9 @@ imports, synchronization, quality review, and reversible merge operations. reads never infer deletions. - Merge and communication-governance operations append auditable evidence and never silently erase prior state. +- Request feedback is rendered as a compact shared alert over the full-height + workspace. It does not become a grid row or displace the source, contact, and + detail columns. Backend and WebUI manifests publish matching route/section/action surfaces. English and German catalogues include the owned interaction vocabulary; major diff --git a/src/govoplan_addresses/backend/service.py b/src/govoplan_addresses/backend/service.py index a5aa010..fa2a19a 100644 --- a/src/govoplan_addresses/backend/service.py +++ b/src/govoplan_addresses/backend/service.py @@ -12,7 +12,7 @@ import unicodedata import urllib.parse from typing import Any -from sqlalchemy import and_, false, func, or_ +from sqlalchemy import and_, exists, false, func, or_ from sqlalchemy.orm import Session, selectinload from govoplan_core.auth import ApiPrincipal @@ -3256,21 +3256,26 @@ def _filtered_contact_query( ) if address_book_id and address_list.address_book_id != address_book_id: raise AddressBookError("Address list does not belong to the selected address book.") - contact_query = contact_query.join( - AddressListEntry, - AddressListEntry.contact_id == Contact.id, - ).filter(AddressListEntry.address_list_id == address_list_id) + contact_query = contact_query.filter( + exists().where( + AddressListEntry.contact_id == Contact.id, + AddressListEntry.address_list_id == address_list_id, + ) + ) normalized_query = _trim(query) if normalized_query: pattern = f"%{normalized_query.lower()}%" - contact_query = contact_query.outerjoin(ContactEmail).filter( + contact_query = contact_query.filter( or_( func.lower(Contact.display_name).like(pattern), func.lower(Contact.organization).like(pattern), - func.lower(ContactEmail.email).like(pattern), + exists().where( + ContactEmail.contact_id == Contact.id, + func.lower(ContactEmail.email).like(pattern), + ), ) ) - return contact_query.distinct() + return contact_query def get_visible_contact(session: Session, principal: ApiPrincipal, contact_id: str, *, include_deleted: bool = False) -> Contact: diff --git a/tests/test_addresses_service.py b/tests/test_addresses_service.py index c6be140..b875f45 100644 --- a/tests/test_addresses_service.py +++ b/tests/test_addresses_service.py @@ -6,6 +6,7 @@ from datetime import timedelta from unittest.mock import patch from sqlalchemy import create_engine, inspect +from sqlalchemy.dialects import postgresql from sqlalchemy.orm import sessionmaker from govoplan_core.core.change_sequence import ChangeSequenceEntry @@ -142,6 +143,7 @@ from govoplan_addresses.backend.service import ( suggest_duplicate_contacts, resolve_trusted_deployment_carddav_credential_ref, _carddav_client_for_source, + _filtered_contact_query, ) @@ -309,6 +311,27 @@ class AddressServiceTest(unittest.TestCase): self.session.commit() self.assertEqual([item.id for item in list_contacts(self.session, self.principal, address_book_id=book.id)], [contact.id]) + def test_contact_query_is_postgresql_json_safe(self) -> None: + book = create_address_book( + self.session, + self.principal, + AddressBookCreateRequest(scope_type="user", name="PostgreSQL-safe"), + ) + self.session.commit() + + contact_query = _filtered_contact_query( + self.session, + self.principal, + address_book_id=book.id, + address_list_id=None, + query="example", + include_deleted=False, + ) + compiled = str(contact_query.statement.compile(dialect=postgresql.dialect())) + + self.assertNotIn("SELECT DISTINCT", compiled.upper()) + self.assertIn("EXISTS", compiled.upper()) + def test_contact_and_relationship_routes_emit_value_free_audit_evidence(self) -> None: book = create_address_book( self.session, diff --git a/webui/src/features/addressbook/AddressBookPage.tsx b/webui/src/features/addressbook/AddressBookPage.tsx index 7b2e8a0..65c4e9b 100644 --- a/webui/src/features/addressbook/AddressBookPage.tsx +++ b/webui/src/features/addressbook/AddressBookPage.tsx @@ -2978,8 +2978,8 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props) return (