Skip to content

Commit 6996294

Browse files
authored
OpenConceptLab/ocl_online#456 | Load a page's concept mappings once for includeMappings concept lists (#949)
* OpenConceptLab/ocl_online#456 | Load a page's concept mappings once for includeMappings concept lists * OpenConceptLab/ocl_online#456 | fixing pylint * OpenConceptLab/ocl_online#456 | API-level tests for batched includeMappings across concept list endpoints
1 parent 55f30f2 commit 6996294

4 files changed

Lines changed: 146 additions & 3 deletions

File tree

‎core/common/serializers.py‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -262,6 +262,9 @@ def to_representation(self, data):
262262
self.child.Meta.model.load_list_fields(
263263
instances, self.child.fields.keys(), get_collection_in_scope(self.context),
264264
get(self.context, 'request.user') or get(self.context, 'user'))
265+
preload_page = getattr(self.child, 'preload_page', None)
266+
if preload_page:
267+
preload_page(instances)
265268
return super().to_representation(instances)
266269

267270

‎core/concepts/serializers.py‎

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
from django.db.models import F
12
from pydash import get, compact
23
from rest_framework.fields import CharField, DateTimeField, BooleanField, URLField, JSONField, SerializerMethodField, \
34
UUIDField, ListField, IntegerField, ReadOnlyField
@@ -234,10 +235,52 @@ def get_references(self, obj):
234235
return obj.collection_references_uris(collection)
235236
return None
236237

237-
def get_mappings(self, obj):
238+
def mapping_serializer_class(self):
239+
from core.mappings.serializers import MappingDetailSerializer, MappingMinimalSerializer
240+
request = get(self, 'context.request')
241+
is_mapping_brief = request.query_params.get('mappingBrief', None) in TRUTHY
242+
return MappingMinimalSerializer if is_mapping_brief else MappingDetailSerializer
243+
244+
def preloads_direct_mappings(self):
245+
return 'mappings' in self.fields and self.include_direct_mappings and not (
246+
self.include_indirect_mappings or 'collection' in self.view_kwargs)
247+
248+
def preload_page(self, instances):
249+
"""Loads the direct mappings of a whole page of concepts in one go, instead of per concept."""
250+
request = get(self, 'context.request')
251+
if not (instances and request and self.preloads_direct_mappings()):
252+
return
253+
from core.mappings.models import Mapping
254+
params = request.query_params
255+
concept_ids = {
256+
concept.id: set(compact([
257+
concept.id, concept.versioned_object_id, get(concept.get_latest_version(), 'id')]))
258+
for concept in instances
259+
}
260+
mappings = Mapping.objects.filter(
261+
parent_id__in={concept.parent_id for concept in instances},
262+
from_concept_id__in=set().union(*concept_ids.values()), id=F('versioned_object_id'))
263+
if map_types := compact((params.get('mapTypes', '') or '').split(',')):
264+
mappings = mappings.filter(map_type__in=map_types)
265+
if target_repo_urls := compact((params.get('targetRepoUrls', '') or '').split(',')):
266+
mappings = mappings.filter(to_source_url__in=target_repo_urls)
267+
if params.get(INCLUDE_RETIRED_PARAM, None) not in TRUTHY:
268+
mappings = mappings.filter(retired=False)
269+
mappings = list(mappings.order_by('id'))
270+
Mapping.load_list_fields(mappings, self.mapping_serializer_class()().fields.keys(), None, request.user)
271+
for concept in instances:
272+
concept._preloaded_mappings = [ # pylint: disable=protected-access
273+
mapping for mapping in mappings
274+
if mapping.parent_id == concept.parent_id and mapping.from_concept_id in concept_ids[concept.id]]
275+
276+
def get_mappings(self, obj): # pylint: disable=too-many-locals
238277
from core.mappings.serializers import MappingDetailSerializer, MappingMinimalSerializer
239278
context = get(self, 'context')
240279
request = get(context, 'request')
280+
preloaded = getattr(obj, '_preloaded_mappings', None)
281+
if preloaded is not None and self.preloads_direct_mappings():
282+
child = self.mapping_serializer_class()()
283+
return [child.to_representation(mapping) for mapping in preloaded]
241284
is_mapping_brief = request.query_params.get('mappingBrief', None) in TRUTHY
242285
map_types = compact((request.query_params.get('mapTypes', '') or '').split(','))
243286
target_repo_urls = compact((request.query_params.get('targetRepoUrls', '') or '').split(','))

‎core/concepts/tests/tests.py‎

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@
66
from elasticsearch_dsl.utils import AttrDict
77
from django.contrib.auth.models import Group
88
from django.core.exceptions import ValidationError
9-
from django.db import IntegrityError
9+
from django.db import IntegrityError, connection
10+
from django.test.utils import CaptureQueriesContext
1011
from django.http import Http404, QueryDict
1112
from django.test import override_settings
1213
from pydash import omit
@@ -3116,6 +3117,26 @@ def test_get_mappings_direct_with_map_types_and_target_repo_urls_filters(self):
31163117
result = serializer.get_mappings(concept1)
31173118
self.assertEqual(result, [])
31183119

3120+
def test_list_serializer_loads_page_mappings_once(self):
3121+
source = OrganizationSourceFactory()
3122+
concepts = [ConceptFactory(parent=source) for _ in range(6)]
3123+
for concept in concepts[:5]:
3124+
MappingFactory(from_concept=concept, to_concept=concepts[5], parent=source, map_type='Same As')
3125+
MappingFactory(from_concept=concept, to_concept=concepts[5], parent=source, map_type='Other')
3126+
query = 'includeMappings=true&mapTypes=Same As,Other'
3127+
context = self.build_context(query)
3128+
3129+
rows = [ConceptListSerializer(concept, context=context).data['mappings'] for concept in concepts]
3130+
with CaptureQueriesContext(connection) as small:
3131+
page = ConceptListSerializer(concepts[:2], many=True, context=context).data
3132+
with CaptureQueriesContext(connection) as large:
3133+
page = ConceptListSerializer(concepts, many=True, context=context).data
3134+
3135+
by_id = lambda mappings: sorted(mappings, key=lambda mapping: mapping['uuid']) # noqa: E731
3136+
self.assertEqual([by_id(row['mappings']) for row in page], [by_id(row) for row in rows])
3137+
self.assertEqual(len(rows[0]), 2)
3138+
self.assertLess(len(large) - len(small), 4 * 4)
3139+
31193140
def test_get_mappings_returns_empty_list_by_default(self):
31203141
concept = ConceptFactory()
31213142
serializer = ConceptListSerializer(context=self.build_context())

‎core/integration_tests/tests_concepts.py‎

Lines changed: 77 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@
2828
from core.concepts.documents import ConceptDocument
2929
from core.concepts.models import Concept
3030
from core.concepts.search import ConceptFuzzySearch
31-
from core.concepts.serializers import ConceptListSerializer, ConceptDetailSerializer
31+
from core.concepts.serializers import ConceptListSerializer, ConceptDetailSerializer, ConceptAbstractSerializer
3232
from core.concepts.tests.factories import ConceptFactory, ConceptNameFactory, ConceptDescriptionFactory
3333
from core.concepts.views import MetadataToConceptsListView
3434
from core.integration_tests.test_utils import SourceChildListQueryCountMixin
@@ -4628,3 +4628,79 @@ def test_post_429_refused_by_capacity_limit(self, reranker_mock):
46284628
self.assertEqual(response.status_code, 429)
46294629
self.assertEqual(response.data['error_code'], 'capacity_exceeded')
46304630
reranker_mock.assert_not_called()
4631+
4632+
4633+
class ConceptListIncludeMappingsTest(OCLAPITestCase):
4634+
def setUp(self):
4635+
super().setUp()
4636+
self.source = OrganizationSourceFactory()
4637+
self.other_source = OrganizationSourceFactory()
4638+
self.concepts = [ConceptFactory(parent=self.source) for _ in range(3)]
4639+
self.other_concept = ConceptFactory(parent=self.other_source)
4640+
self.mappings = {}
4641+
for concept in self.concepts:
4642+
self.mappings[concept.mnemonic] = {
4643+
MappingFactory(
4644+
parent=self.source, from_concept=concept, to_concept=self.concepts[0], map_type=f'type{index}'
4645+
).id for index in range(2)
4646+
}
4647+
self.mappings[self.other_concept.mnemonic] = {MappingFactory(
4648+
parent=self.other_source, from_concept=self.other_concept, to_concept=self.other_concept).id}
4649+
4650+
def mappings_by_concept(self, url):
4651+
response = self.client.get(url + ('&' if '?' in url else '?') + 'verbose=true&includeMappings=true')
4652+
self.assertEqual(response.status_code, 200)
4653+
return {row['id']: sorted(mapping['uuid'] for mapping in row['mappings']) for row in response.data}
4654+
4655+
def assert_same_with_and_without_batching(self, url, expected=None):
4656+
batched = self.mappings_by_concept(url)
4657+
with patch.object(ConceptAbstractSerializer, 'preload_page', lambda *args: None):
4658+
per_concept = self.mappings_by_concept(url)
4659+
self.assertEqual(batched, per_concept)
4660+
if expected is not None:
4661+
self.assertEqual(
4662+
batched, {mnemonic: sorted(str(_id) for _id in ids) for mnemonic, ids in expected.items()})
4663+
4664+
def test_source_head_list(self):
4665+
self.assert_same_with_and_without_batching(
4666+
self.source.uri + 'concepts/', {c.mnemonic: self.mappings[c.mnemonic] for c in self.concepts})
4667+
4668+
def test_global_list_across_sources(self):
4669+
batched = self.mappings_by_concept('/concepts/')
4670+
self.assertEqual(len(batched), 4)
4671+
self.assert_same_with_and_without_batching('/concepts/', {
4672+
mnemonic: ids for mnemonic, ids in self.mappings.items()})
4673+
4674+
def test_source_version_list(self):
4675+
version = OrganizationSourceFactory(
4676+
organization=self.source.organization, mnemonic=self.source.mnemonic, version='v1')
4677+
for concept in self.concepts:
4678+
version.concepts.add(concept.get_latest_version())
4679+
self.assert_same_with_and_without_batching(version.uri + 'concepts/')
4680+
self.assertTrue(any(self.mappings_by_concept(version.uri + 'concepts/').values()))
4681+
4682+
def test_concept_versions_list(self):
4683+
self.assert_same_with_and_without_batching(
4684+
self.concepts[1].versions_url, {self.concepts[1].mnemonic: self.mappings[self.concepts[1].mnemonic]})
4685+
4686+
def test_collection_list_ignores_mappings_outside_expansion(self):
4687+
collection = OrganizationCollectionFactory()
4688+
expansion = ExpansionFactory(collection_version=collection)
4689+
collection.expansion_uri = expansion.uri
4690+
collection.save()
4691+
concept = self.concepts[0]
4692+
kept = Mapping.objects.filter(parent=self.source, from_concept=concept).first()
4693+
expansion.concepts.add(concept.get_latest_version())
4694+
expansion.mappings.add(kept)
4695+
4696+
url = collection.uri + 'HEAD/concepts/'
4697+
self.assert_same_with_and_without_batching(url)
4698+
shown = {uuid for uuids in self.mappings_by_concept(url).values() for uuid in uuids}
4699+
self.assertLessEqual(shown, {str(kept.id)})
4700+
4701+
def test_inverse_mappings_are_unchanged(self):
4702+
url = self.source.uri + 'concepts/?verbose=true&includeInverseMappings=true'
4703+
batched = self.client.get(url).data
4704+
with patch.object(ConceptAbstractSerializer, 'preload_page', lambda *args: None):
4705+
per_concept = self.client.get(url).data
4706+
self.assertEqual(batched, per_concept)

0 commit comments

Comments
 (0)