diff --git a/arkindex/documents/api/elements.py b/arkindex/documents/api/elements.py index a744a3cd9fb61d53985b5ed0d6e91381f0052aa8..41df04fa10ddec50671b02d10a561160e4935555 100644 --- a/arkindex/documents/api/elements.py +++ b/arkindex/documents/api/elements.py @@ -8,18 +8,7 @@ from uuid import UUID from django.conf import settings from django.core.exceptions import ValidationError as DjangoValidationError from django.db import connection, transaction -from django.db.models import ( - CharField, - Count, - F, - FloatField, - Max, - Prefetch, - Q, - QuerySet, - Value, - prefetch_related_objects, -) +from django.db.models import CharField, Count, F, FloatField, Prefetch, Q, QuerySet, Value, prefetch_related_objects from django.db.models.functions import Cast from django.shortcuts import get_object_or_404 from django.utils.functional import cached_property @@ -2126,27 +2115,12 @@ class ElementBulkCreate(CreateAPIView): worker_run = serializer.validated_data['worker_run'] elements = serializer.validated_data['elements'] - # ElementPath.get_next_order, but for multiple types - next_orderings = defaultdict( - int, - ElementPath - .objects - .using('default') - .filter( - # Using __overlap before __last makes Postgres use the GIN index instead of scanning the whole table - path__overlap=[self.element.id], - path__last=self.element.id, - element__type_id__in=set(element_data['type'] for element_data in elements) - ) - .values('element__type_id') - .annotate(next_ordering=Max('ordering') + 1) - .values_list('element__type_id', 'next_ordering') - ) + next_ordering = self.element.get_next_order() # Prepare elements for element_data in elements: # Keep ordering in the element payload to optimize element path creation later - element_data['ordering'] = next_orderings[element_data['type']] - next_orderings[element_data['type']] += 1 + element_data['ordering'] = next_ordering + next_ordering += 1 element_data['element'] = Element( id=uuid.uuid4(), diff --git a/arkindex/documents/api/ml.py b/arkindex/documents/api/ml.py index 2538611d2037e608a134155b4c7965dc9ab39ba4..a951a0bb6b25b030377e0306e23e4d2a031ef5d0 100644 --- a/arkindex/documents/api/ml.py +++ b/arkindex/documents/api/ml.py @@ -236,7 +236,7 @@ class ElementTranscriptionsBulk(CreateAPIView): if not paths: # Support top level elements, by adding an empty initial path to trigger loops below paths = [[]] - next_path_ordering = self.element.get_next_order(elt_type) + next_path_ordering = self.element.get_next_order() for annotation in annotations: # Look for a direct children with the right type and zone annotation['element'] = children.get(annotation['polygon'].wkb) diff --git a/arkindex/documents/fixtures/data.json b/arkindex/documents/fixtures/data.json index f64a4cbc4eef85fa5f00179e5eee3a54770dc185..980e53242119a968ad7e33f2526fced4c60464a8 100644 --- a/arkindex/documents/fixtures/data.json +++ b/arkindex/documents/fixtures/data.json @@ -485,7 +485,7 @@ "fields": { "element": "baea57f6-ac29-441f-9f54-6ba6fd6a9ad5", "path": "[\"61028c97-6f49-4cdd-b85f-802929964e86\"]", - "ordering": 2 + "ordering": 5 } }, { @@ -521,7 +521,7 @@ "fields": { "element": "e88a7ef5-c172-41b3-b180-bcdcf96598da", "path": "[\"61028c97-6f49-4cdd-b85f-802929964e86\"]", - "ordering": 1 + "ordering": 4 } }, { @@ -548,7 +548,7 @@ "fields": { "element": "fb66f858-83d5-404b-bd16-75c1e41b4c9b", "path": "[\"61028c97-6f49-4cdd-b85f-802929964e86\"]", - "ordering": 0 + "ordering": 3 } }, { @@ -575,7 +575,7 @@ "fields": { "element": "d47bd411-be3c-4f08-9b2c-b886e91b9513", "path": "[\"61028c97-6f49-4cdd-b85f-802929964e86\", \"2b1a6efb-6667-4329-a2da-13727695b8a4\"]", - "ordering": 0 + "ordering": 3 } }, { @@ -611,7 +611,7 @@ "fields": { "element": "a328d7ee-624a-489b-9dff-6cc32a1935df", "path": "[\"61028c97-6f49-4cdd-b85f-802929964e86\"]", - "ordering": 3 + "ordering": 6 } }, { @@ -683,7 +683,7 @@ "fields": { "element": "9b4bb46a-b466-4d24-a23f-2c58a04cf47a", "path": "[\"61028c97-6f49-4cdd-b85f-802929964e86\"]", - "ordering": 4 + "ordering": 7 } }, { diff --git a/arkindex/documents/migrations/0065_elementpath_constraints.py b/arkindex/documents/migrations/0065_elementpath_constraints.py new file mode 100644 index 0000000000000000000000000000000000000000..6f8ad212c60b860c5708f0bc3cbfbbe79c492046 --- /dev/null +++ b/arkindex/documents/migrations/0065_elementpath_constraints.py @@ -0,0 +1,40 @@ +# Generated by Django 4.1.7 on 2023-04-26 14:12 + +from django.contrib.postgres.constraints import ExclusionConstraint +from django.contrib.postgres.fields import RangeOperators +from django.db import migrations +from django.db.models import Deferrable + + +class Migration(migrations.Migration): + + dependencies = [ + ('documents', '0064_alter_entity_type_alter_entityrole_child_type_and_more'), + ] + + operations = [ + migrations.AddConstraint( + model_name='elementpath', + constraint=ExclusionConstraint( + name='unique_element_orderings', + expressions=[ + ('element', RangeOperators.EQUAL), + ('path__last', RangeOperators.EQUAL), + ('ordering', RangeOperators.NOT_EQUAL), + ], + deferrable=Deferrable.DEFERRED, + ), + ), + migrations.AddConstraint( + model_name='elementpath', + constraint=ExclusionConstraint( + name='unique_parent_orderings', + expressions=[ + ('path__last', RangeOperators.EQUAL), + ('ordering', RangeOperators.EQUAL), + ('element', RangeOperators.NOT_EQUAL), + ], + deferrable=Deferrable.DEFERRED, + ), + ), + ] diff --git a/arkindex/documents/models.py b/arkindex/documents/models.py index b8f3c5bae8ecbaae38808e73298dff4b6e4443ab..b4990d7dac5c5cc3972bd102c4e9b3fa935916ea 100644 --- a/arkindex/documents/models.py +++ b/arkindex/documents/models.py @@ -119,7 +119,36 @@ class ElementPath(models.Model): (Least('path__len', 1), RangeOperators.NOT_EQUAL), ], deferrable=Deferrable.DEFERRED, - ) + ), + # An element cannot be in two positions within the same parent, but its paths can be duplicated + # as there might be multiple paths to handle multiple grandparents on the same parent. + # We cannot use a unique constraint, but we can use an exclusion constraint to forbid different orderings. + ExclusionConstraint( + name='unique_element_orderings', + expressions=[ + # On the same element… + ('element', RangeOperators.EQUAL), + # Within the same parent… + ('path__last', RangeOperators.EQUAL), + # There cannot be any orderings different from each other. + ('ordering', RangeOperators.NOT_EQUAL), + ], + deferrable=Deferrable.DEFERRED, + ), + # Within the same parent, there cannot be two elements with the same ordering, but since paths can be + # duplicated, there can be the same element twice with the same ordering, so exclusion constraint again. + ExclusionConstraint( + name='unique_parent_orderings', + expressions=[ + # Within the same parent… + ('path__last', RangeOperators.EQUAL), + # At the same ordering… + ('ordering', RangeOperators.EQUAL), + # There cannot be any element IDs different from each other. + ('element', RangeOperators.NOT_EQUAL), + ], + deferrable=Deferrable.DEFERRED, + ), ] @@ -260,6 +289,7 @@ class Element(IndexableModel): # - Whether the parent is a descendant of the child, which would create a cycle # - How many parents the parent has # - The first path of any ElementPath of the parent + # - The next available ordering for direct children of the parent # This can almost entirely be expressed using a QuerySet, but Django is stubborn # and always tries to add a GROUP BY documents_elementpath.id, which returns # wildly incorrect results. @@ -279,14 +309,28 @@ class Element(IndexableModel): EXISTS (SELECT 1 FROM child_paths WHERE path @> ARRAY[%(parent_id)s]), EXISTS (SELECT 1 FROM parent_paths WHERE path @> ARRAY[%(self_id)s]), (SELECT COUNT(*) FROM parent_paths), - (SELECT path FROM parent_paths LIMIT 1) + (SELECT path FROM parent_paths LIMIT 1), + ( + SELECT COALESCE(MAX(ordering) + 1, 0) + FROM documents_elementpath + WHERE path @> ARRAY[%(parent_id)s] + AND path[array_length(path, 1)] = %(parent_id)s + ) """, { 'self_id': self.id, 'parent_id': parent.id, }, ) - has_paths, existing_parent_path, already_has_parent, causes_cycle, parent_paths_count, first_parent_path = cursor.fetchone() + ( + has_paths, + existing_parent_path, + already_has_parent, + causes_cycle, + parent_paths_count, + first_parent_path, + ordering, + ) = cursor.fetchone() # If the child we are trying to add is among the parents of the new parent, # then it would cause a cycle; an element cannot be its own ancestor. @@ -304,10 +348,6 @@ class Element(IndexableModel): parent_paths_count = 1 first_parent_path = [] - # Get the next order for this type in the parent - # TODO: Move this into the first query once orderings are independent of element types - ordering = parent.get_next_order(self.type_id if type_ordering else None) - # Add or update parent paths for this element first. # When the child element has other parents, or when the child element has no paths (not even top-level), # we only need to insert new paths. @@ -417,21 +457,20 @@ class Element(IndexableModel): } ) - def get_next_order(self, type): + def get_next_order(self): """ Find the next ordering for a new child in ElementPath Uses the primary database to avoid stale reads and duplicate orderings, and path__overlap to let Postgres use the GIN index before filtering by last item. + Using the GIN index before path__last is quicker than using path__last alone. """ - assert type is None or isinstance(type, (ElementType, uuid.UUID)) - - paths = ElementPath.objects \ - .using('default') \ - .filter(path__overlap=[self.id], path__last=self.id) - if type: - paths = paths.filter(element__type=type) - - return paths.aggregate(max=models.Max('ordering') + 1)['max'] or 0 + return ( + ElementPath + .objects + .using('default') + .filter(path__overlap=[self.id], path__last=self.id) + .aggregate(max=models.Max('ordering') + 1)['max'] or 0 + ) @transaction.atomic def remove_child(self, child): diff --git a/arkindex/documents/tests/commands/test_move_lines_to_parent.py b/arkindex/documents/tests/commands/test_move_lines_to_parent.py index 9719738adb5597757fbb7424a30f7e3fd228820a..ae29bbec02b3c1ad2c945e7d162afd76cba85a22 100644 --- a/arkindex/documents/tests/commands/test_move_lines_to_parent.py +++ b/arkindex/documents/tests/commands/test_move_lines_to_parent.py @@ -38,7 +38,7 @@ class TestMoveLinesToParentCommand(FixtureTestCase): self.assertEqual(len(parent_paths), 1) self.assertListEqual(list(parent_paths.values('path')), [{"path": [self.double_page.id]}]) - with self.assertNumQueries(19): + with self.assertNumQueries(18): call_command('move_lines_to_parent', corpus=self.corpus, single_page_type='single_page', double_page_type='double_page') # Make sure that lines have been moved @@ -57,7 +57,7 @@ class TestMoveLinesToParentCommand(FixtureTestCase): self.assertEqual(len(parent_paths), 1) self.assertListEqual(list(parent_paths.values('path')), [{"path": [self.double_page.id]}]) - with self.assertNumQueries(19): + with self.assertNumQueries(18): call_command('move_lines_to_parent', corpus=self.corpus, single_page_type='single_page', double_page_type='double_page') # Make sure that paragraph have been moved @@ -102,7 +102,7 @@ class TestMoveLinesToParentCommand(FixtureTestCase): self.assertEqual(len(parent_paths), 1) self.assertListEqual(list(parent_paths.values('path')), [{"path": [self.double_page.id]}]) - with self.assertNumQueries(30): + with self.assertNumQueries(28): call_command('move_lines_to_parent', corpus=self.corpus, single_page_type='single_page', double_page_type='double_page') for line in [same_x_text_line, same_y_text_line]: diff --git a/arkindex/documents/tests/tasks/test_move_element.py b/arkindex/documents/tests/tasks/test_move_element.py index 2bd029f1b28f8ab34bfce2a462ea5f17eb8d4db3..4870ae595789363ccebbeafb02d713ef27fcd0dc 100644 --- a/arkindex/documents/tests/tasks/test_move_element.py +++ b/arkindex/documents/tests/tasks/test_move_element.py @@ -11,7 +11,6 @@ class TestMoveElement(FixtureTestCase): @classmethod def setUpTestData(cls): super().setUpTestData() - cls.page_type = cls.corpus.types.get(slug='page') cls.destination = cls.corpus.elements.get(name='Volume 2') cls.parent = cls.corpus.elements.get(name='Volume 1') cls.source_with_children = cls.corpus.elements.get(name='Volume 1, page 1r') @@ -30,7 +29,6 @@ class TestMoveElement(FixtureTestCase): 'source_id': str(self.source_without_child.id), 'parent_id': str(self.parent.id), 'destination_id': str(self.destination.id), - 'page_type_id': str(self.page_type.id), 'savepoints': [f"s{_thread.get_ident()}_x{connections['default'].savepoint_state + 1}", f"s{_thread.get_ident()}_x{connections['default'].savepoint_state + 2}"] }): move_element(self.source_without_child, self.destination) @@ -57,7 +55,6 @@ class TestMoveElement(FixtureTestCase): 'source_id': str(self.source_with_children.id), 'parent_id': str(self.parent.id), 'destination_id': str(self.destination.id), - 'page_type_id': str(self.page_type.id), 'savepoints': [f"s{_thread.get_ident()}_x{connections['default'].savepoint_state + 1}", f"s{_thread.get_ident()}_x{connections['default'].savepoint_state + 2}"] }): move_element(self.source_with_children, self.destination) diff --git a/arkindex/documents/tests/test_bulk_element_transcriptions.py b/arkindex/documents/tests/test_bulk_element_transcriptions.py index 932b7258addee9a850a7f31c88e2fcb9ca14f30c..d6a2a1585379f2c322a882a005b8bc847f794a91 100644 --- a/arkindex/documents/tests/test_bulk_element_transcriptions.py +++ b/arkindex/documents/tests/test_bulk_element_transcriptions.py @@ -106,8 +106,8 @@ class TestBulkElementTranscriptions(FixtureAPITestCase): for elt in created_elts ], [ - (1, '2', ((13, 37), (13, 137), (133, 137), (133, 37), (13, 37)), self.worker_version.id, self.local_worker_run.id), - (2, '3', ((24, 42), (24, 142), (64, 142), (64, 42), (24, 42)), self.worker_version.id, self.local_worker_run.id) + (4, '5', ((13, 37), (13, 137), (133, 137), (133, 37), (13, 37)), self.worker_version.id, self.local_worker_run.id), + (5, '6', ((24, 42), (24, 142), (64, 142), (64, 42), (24, 42)), self.worker_version.id, self.local_worker_run.id) ] ) self.assertCountEqual( @@ -166,8 +166,8 @@ class TestBulkElementTranscriptions(FixtureAPITestCase): for elt in created_elts ], [ - (1, '2', ((13, 37), (13, 137), (133, 137), (133, 37), (13, 37)), self.worker_version.id, self.local_worker_run.id, 0.5), - (2, '3', ((24, 42), (24, 142), (64, 142), (64, 42), (24, 42)), self.worker_version.id, self.local_worker_run.id, 0.7) + (4, '5', ((13, 37), (13, 137), (133, 137), (133, 37), (13, 37)), self.worker_version.id, self.local_worker_run.id, 0.5), + (5, '6', ((24, 42), (24, 142), (64, 142), (64, 42), (24, 42)), self.worker_version.id, self.local_worker_run.id, 0.7) ] ) self.assertCountEqual( @@ -859,7 +859,7 @@ class TestBulkElementTranscriptions(FixtureAPITestCase): ) ], [ - (1, '2', self.worker_run.version.id, ['I <3 JavaScript']), + (4, '5', self.worker_run.version.id, ['I <3 JavaScript']), ] ) @@ -902,6 +902,6 @@ class TestBulkElementTranscriptions(FixtureAPITestCase): ) ], [ - (1, '2', local_worker_run.version.id, ['I <3 JavaScript']), + (4, '5', local_worker_run.version.id, ['I <3 JavaScript']), ] ) diff --git a/arkindex/documents/tests/test_bulk_elements.py b/arkindex/documents/tests/test_bulk_elements.py index 0e280a0c7e2ec5358fa33b347d7643b04759f17f..e2d3a5f0f6407d8da4ffbc5d3851055eeb417724 100644 --- a/arkindex/documents/tests/test_bulk_elements.py +++ b/arkindex/documents/tests/test_bulk_elements.py @@ -184,10 +184,10 @@ class TestBulkElements(FixtureAPITestCase): self.assertEqual(a_path1.ordering, 0) self.assertEqual(a_path2.ordering, 0) - self.assertEqual(b_path1.ordering, 0) - self.assertEqual(b_path2.ordering, 0) - self.assertEqual(c_path1.ordering, 1) - self.assertEqual(c_path2.ordering, 1) + self.assertEqual(b_path1.ordering, 1) + self.assertEqual(b_path2.ordering, 1) + self.assertEqual(c_path1.ordering, 2) + self.assertEqual(c_path2.ordering, 2) def test_bulk_create_top_level(self): @@ -499,8 +499,8 @@ class TestBulkElements(FixtureAPITestCase): list(elements.values_list('name', 'type__slug', 'paths__ordering', 'polygon')), [ ('A', 'act', 0, LineString(self.payload['elements'][0]['polygon'])), - ('B', 'surface', 0, LineString(self.payload['elements'][1]['polygon'])), - ('C', 'surface', 1, LineString(self.payload['elements'][2]['polygon'])), + ('B', 'surface', 1, LineString(self.payload['elements'][1]['polygon'])), + ('C', 'surface', 2, LineString(self.payload['elements'][2]['polygon'])), ], ) diff --git a/arkindex/documents/tests/test_children_elements.py b/arkindex/documents/tests/test_children_elements.py index 645c6ec0bdc9be09046a2f59112eda15740890ea..d016ff3f1c78a68232c649222e115d3dd1c92882 100644 --- a/arkindex/documents/tests/test_children_elements.py +++ b/arkindex/documents/tests/test_children_elements.py @@ -76,8 +76,8 @@ class TestChildrenElements(FixtureAPITestCase): self.assertListEqual( [r['name'] for r in response.json()['results']], [ - 'Act 2', 'Volume 1, page 2r', + 'Act 2', ] ) @@ -381,10 +381,11 @@ class TestChildrenElements(FixtureAPITestCase): 'Act 5', 'Act 1', 'Act 2', - 'Act 3' + 'Act 3', ] + offset = Element.objects.filter(paths__path__last=self.vol.id).exclude(type__slug='act').count() for ordering, name in enumerate(expected): - Element.objects.get(type__slug='act', name=name).paths.update(ordering=ordering) + Element.objects.get(type__slug='act', name=name).paths.update(ordering=ordering + offset) response = self.client.get( reverse('api:elements-children', kwargs={'pk': str(self.vol.id)}) @@ -500,16 +501,16 @@ class TestChildrenElements(FixtureAPITestCase): reverse('api:elements-children', kwargs={'pk': str(page.id)}), {'recursive': True}, ) - self.assertEqual(response.status_code, status.HTTP_200_OK) - # cell_b and cell_c are duplicated due to a bug in Element.objects.get_descending + self.assertEqual(response.status_code, status.HTTP_200_OK) + # col1, cell_b and cell_c are duplicated due to a bug in Element.objects.get_descending # See https://gitlab.com/teklia/arkindex/backend/-/issues/769 - self.assertEqual(response.json()['count'], 12) self.assertCountEqual([item['id'] for item in response.json()['results']], [ str(table_1.id), str(table_2.id), str(row1.id), str(row2.id), str(col1.id), + str(col1.id), str(col2.id), str(cell_a.id), str(cell_b.id), diff --git a/arkindex/documents/tests/test_create_elements.py b/arkindex/documents/tests/test_create_elements.py index ade8d13d2db47e1872ac72ff2763ee14e07892ff..7b6ae2a6f3d234bdcee97e542e308b41e8ab56cd 100644 --- a/arkindex/documents/tests/test_create_elements.py +++ b/arkindex/documents/tests/test_create_elements.py @@ -124,7 +124,7 @@ class TestCreateElements(FixtureAPITestCase): name='The castle of my dreams', image=str(self.image.id), ) - with self.assertNumQueries(16): + with self.assertNumQueries(15): response = self.client.post(**request) self.assertEqual(response.status_code, status.HTTP_201_CREATED) data = response.json() @@ -179,7 +179,7 @@ class TestCreateElements(FixtureAPITestCase): name='The castle of my dreams', polygon=polygon, ) - with self.assertNumQueries(16): + with self.assertNumQueries(15): response = self.client.post(**request) self.assertEqual(response.status_code, status.HTTP_201_CREATED) data = response.json() @@ -199,7 +199,7 @@ class TestCreateElements(FixtureAPITestCase): name='Castle story', elt_type='act' ) - with self.assertNumQueries(15): + with self.assertNumQueries(14): response = self.client.post(**request) self.assertEqual(response.status_code, status.HTTP_201_CREATED) act = Element.objects.get(id=response.json()['id']) @@ -249,7 +249,7 @@ class TestCreateElements(FixtureAPITestCase): image=str(self.image.id), polygon=polygon ) - with self.assertNumQueries(16): + with self.assertNumQueries(15): response = self.client.post(**request) self.assertEqual(response.status_code, status.HTTP_201_CREATED) data = response.json() @@ -299,7 +299,7 @@ class TestCreateElements(FixtureAPITestCase): image=str(self.huge_image.id), polygon=polygon ) - with self.assertNumQueries(16): + with self.assertNumQueries(15): response = self.client.post(**request) self.assertEqual(response.status_code, status.HTTP_201_CREATED, response.json()) self.assertListEqual(response.json()['zone']['polygon'], polygon) @@ -366,7 +366,7 @@ class TestCreateElements(FixtureAPITestCase): ) self.image.status = S3FileStatus.Error self.image.save() - with self.assertNumQueries(16): + with self.assertNumQueries(15): response = self.client.post(**request) self.assertEqual(response.status_code, status.HTTP_201_CREATED) element = self.corpus.elements.get(id=response.json()['id']) @@ -411,7 +411,7 @@ class TestCreateElements(FixtureAPITestCase): image=str(self.image.id), ) request['path'] += '?slim_output=true' - with self.assertNumQueries(14): + with self.assertNumQueries(13): response = self.client.post(**request) self.assertEqual(response.status_code, status.HTTP_201_CREATED) element = self.corpus.elements.get(id=response.json()['id']) @@ -448,7 +448,7 @@ class TestCreateElements(FixtureAPITestCase): if mirrored is not None: request['data']['mirrored'] = mirrored - with self.assertNumQueries(16): + with self.assertNumQueries(15): response = self.client.post(**request) self.assertEqual(response.status_code, status.HTTP_201_CREATED) @@ -513,7 +513,7 @@ class TestCreateElements(FixtureAPITestCase): confidence=0.42, ) - with self.assertNumQueries(16): + with self.assertNumQueries(15): response = self.client.post(**request) self.assertEqual(response.status_code, status.HTTP_201_CREATED) diff --git a/arkindex/documents/tests/test_edit_elementpath.py b/arkindex/documents/tests/test_edit_elementpath.py index c69ca681935c2916cb5e68d593f375b56f3b2070..980f87df36eae1f4eae03cbaaae1036e1554af02 100644 --- a/arkindex/documents/tests/test_edit_elementpath.py +++ b/arkindex/documents/tests/test_edit_elementpath.py @@ -96,7 +96,6 @@ class TestEditElementPath(FixtureTestCase): # add_parent uses transaction.atomic(), and we are running in a unit test, which is already in a transaction. # This will cause a savepoint to be created, with a name that is hard to mock. 'savepoint': f"s{_thread.get_ident()}_x{connections['default'].savepoint_state + 1}", - 'type_id': elements['A'].type_id, 'A': elements['A'].id, 'B': elements['B'].id, 'X': elements['X'].id, @@ -167,7 +166,6 @@ class TestEditElementPath(FixtureTestCase): # add_parent uses transaction.atomic(), and we are running in a unit test, which is already in a transaction. # This will cause a savepoint to be created, with a name that is hard to mock. 'savepoint': f"s{_thread.get_ident()}_x{connections['default'].savepoint_state + 1}", - 'type_id': elements['A'].type_id, 'A': elements['A'].id, 'B': elements['B'].id, 'K': elements['K'].id, @@ -344,6 +342,26 @@ class TestEditElementPath(FixtureTestCase): self.check_parents(elements, 'F', ['X', 'A', 'B', 'E'], ['Y', 'A', 'B', 'E']) + path1, path2 = elements['B'].paths.filter(path__last=elements['A'].id) + + # remove_child picks the first parent path to perform an update on it, but it really just needs any random one, + # therefore it is retrieved without an ordering. This can cause intermittent failures in tests since either + # X or Y will be picked. Adding an ORDER BY in the original method would not help much either since we cannot + # order by any value that would not change randomly during the execution. + # This class implements a workaround that works with assertExactQueries: the __str__ will be called after + # remove_child has run, and just before assertExactQueries checks the queries against the expected SQL. + # B will only have one remaining path: the first path that got picked by remove_child for the update. + # The other path will have been deleted. We can therefore get this remaining path and compare it by its ID + # to the two paths that we had before, and pick the parent that was in the old version of this path. + class FirstParent(object): + def __str__(self): + path_id = elements['B'].paths.get().id + if path1.id == path_id: + return str(path1.path[0]) + elif path2.id == path_id: + return str(path2.path[0]) + raise AssertionError('Unexpected top-level path ID') + with self.assertExactQueries( 'remove_child_last_parent.sql', params={ # remove_child uses transaction.atomic(), and we are running in a unit test, which is already in a transaction. @@ -351,7 +369,7 @@ class TestEditElementPath(FixtureTestCase): 'savepoint': f"s{_thread.get_ident()}_x{connections['default'].savepoint_state + 1}", 'A': elements['A'].id, 'B': elements['B'].id, - 'Y': elements['Y'].id + 'first_parent': FirstParent(), } ): elements['A'].remove_child(elements['B']) diff --git a/arkindex/documents/tests/test_element_paths_api.py b/arkindex/documents/tests/test_element_paths_api.py index 01c77092eb547319eb00977cd7a3d94f071e017d..27dca725a1cd3977287329493718dfd7b18b3b05 100644 --- a/arkindex/documents/tests/test_element_paths_api.py +++ b/arkindex/documents/tests/test_element_paths_api.py @@ -41,7 +41,7 @@ class TestElementsAPI(FixtureAPITestCase): self.client.force_login(self.user) # Link desk to its parent room - with self.assertNumQueries(14): + with self.assertNumQueries(13): response = self.client.post(reverse('api:element-parent', kwargs=self.default_kwargs)) self.assertEqual(response.status_code, status.HTTP_201_CREATED) @@ -50,7 +50,7 @@ class TestElementsAPI(FixtureAPITestCase): self.assertEqual(element_path.ordering, 0) # Link room to its parent house - with self.assertNumQueries(12): + with self.assertNumQueries(11): response = self.client.post(reverse('api:element-parent', kwargs={ 'child': str(self.room.id), 'parent': str(self.house.id) @@ -61,50 +61,6 @@ class TestElementsAPI(FixtureAPITestCase): self.assertListEqual(element_path.path, [self.house.id, self.room.id]) self.assertEqual(element_path.ordering, 0) - def test_create_type_ordering(self): - self.client.force_login(self.user) - self.link_elements() - - # The desk is inside the kitchen in first position - element_path = self.desk.paths.get() - self.assertListEqual(element_path.path, [self.house.id, self.room.id]) - self.assertEqual(element_path.ordering, 0) - - person_type = self.corpus.types.create(slug='person', display_name='Person') - person = self.corpus.elements.create(type=person_type, name='Brian') - - cases = [ - # With type ordering, Brian is the first person in the kitchen - (True, 0), - # Any value that is not 0 or false is treated as true - ('yes', 0), - # Including nothing - ('', 0), - # Without type ordering, Brian is the second element after the desk - (False, 1), - ('false', 1), - ('0', 1), - ] - for type_ordering, expected_ordering in cases: - # Reset all paths before testing - person.paths.all().delete() - person.paths.create(path=[], ordering=0) - - with self.subTest(type_ordering=type_ordering): - with self.assertNumQueries(14): - response = self.client.post( - reverse('api:element-parent', kwargs={ - 'child': str(person.id), - 'parent': str(self.room.id), - }) - + f'?type_ordering={type_ordering}' - ) - self.assertEqual(response.status_code, status.HTTP_201_CREATED) - - element_path = person.paths.get() - self.assertListEqual(element_path.path, [self.house.id, self.room.id]) - self.assertEqual(element_path.ordering, expected_ordering) - def test_create_forbidden(self): response = self.client.post(reverse('api:element-parent', kwargs=self.default_kwargs)) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) diff --git a/arkindex/documents/tests/test_neighbors.py b/arkindex/documents/tests/test_neighbors.py index 6757e1ff7495b89bdc6bd8ada3f529c8ebc28623..546467af3d71d3e3ec322dcacffd900e7a1bf26d 100644 --- a/arkindex/documents/tests/test_neighbors.py +++ b/arkindex/documents/tests/test_neighbors.py @@ -130,7 +130,7 @@ class TestElementNeighbors(FixtureAPITestCase): 'name': elements['Y'].name }] }, { - 'position': 1, + 'position': 3, 'element': { 'id': str(element.id), 'type': 'volume', diff --git a/arkindex/sql_validation/add_first_parent.sql b/arkindex/sql_validation/add_first_parent.sql index 75693fbd327079d0f2a68d452b590cc18e902848..f00ec074bf72221462fba08887420219a79796d5 100644 --- a/arkindex/sql_validation/add_first_parent.sql +++ b/arkindex/sql_validation/add_first_parent.sql @@ -25,14 +25,11 @@ SELECT EXISTS FROM parent_paths), (SELECT path FROM parent_paths - LIMIT 1) ; - -SELECT (MAX("documents_elementpath"."ordering") + 1) AS "max" -FROM "documents_elementpath" -INNER JOIN "documents_element" ON ("documents_elementpath"."element_id" = "documents_element"."id") -WHERE ("documents_elementpath"."path"[array_length("documents_elementpath"."path", 1)] = '{A}'::uuid - AND "documents_elementpath"."path" && (ARRAY['{A}'::uuid])::uuid[] - AND "documents_element"."type_id" = '{type_id}'::uuid); + LIMIT 1), + (SELECT COALESCE(MAX(ordering) + 1, 0) + FROM documents_elementpath + WHERE path @ > ARRAY['{A}'::uuid] + AND path[array_length(path, 1)] = '{A}'::uuid ) ; INSERT INTO documents_elementpath (id, element_id, path, ordering) SELECT uuid_generate_v4(), diff --git a/arkindex/sql_validation/add_second_parent.sql b/arkindex/sql_validation/add_second_parent.sql index 794827a573f304e0d15e2fc8203388138ce0709c..4aaa684eea5dbc14ab76d1af2945d6b748b22d12 100644 --- a/arkindex/sql_validation/add_second_parent.sql +++ b/arkindex/sql_validation/add_second_parent.sql @@ -25,14 +25,11 @@ SELECT EXISTS FROM parent_paths), (SELECT path FROM parent_paths - LIMIT 1) ; - -SELECT (MAX("documents_elementpath"."ordering") + 1) AS "max" -FROM "documents_elementpath" -INNER JOIN "documents_element" ON ("documents_elementpath"."element_id" = "documents_element"."id") -WHERE ("documents_elementpath"."path"[array_length("documents_elementpath"."path", 1)] = '{A}'::uuid - AND "documents_elementpath"."path" && (ARRAY['{A}'::uuid])::uuid[] - AND "documents_element"."type_id" = '{type_id}'::uuid); + LIMIT 1), + (SELECT COALESCE(MAX(ordering) + 1, 0) + FROM documents_elementpath + WHERE path @ > ARRAY['{A}'::uuid] + AND path[array_length(path, 1)] = '{A}'::uuid ) ; INSERT INTO documents_elementpath (id, element_id, path, ordering) SELECT uuid_generate_v4(), diff --git a/arkindex/sql_validation/element_move_with_children.sql b/arkindex/sql_validation/element_move_with_children.sql index 3645cc093a218abe0ea61c5c8758dff3afffebc0..b06caf357c5b6e5e7e8a9a7157429fa416df873f 100644 --- a/arkindex/sql_validation/element_move_with_children.sql +++ b/arkindex/sql_validation/element_move_with_children.sql @@ -60,14 +60,11 @@ SELECT EXISTS FROM parent_paths), (SELECT path FROM parent_paths - LIMIT 1) ; - -SELECT (MAX("documents_elementpath"."ordering") + 1) AS "max" -FROM "documents_elementpath" -INNER JOIN "documents_element" ON ("documents_elementpath"."element_id" = "documents_element"."id") -WHERE ("documents_elementpath"."path"[array_length("documents_elementpath"."path", 1)] = '{destination_id}'::uuid - AND "documents_elementpath"."path" && (ARRAY['{destination_id}'::uuid])::uuid[] - AND "documents_element"."type_id" = '{page_type_id}'::uuid); + LIMIT 1), + (SELECT COALESCE(MAX(ordering) + 1, 0) + FROM documents_elementpath + WHERE path @ > ARRAY['{destination_id}'::uuid] + AND path[array_length(path, 1)] = '{destination_id}'::uuid ) ; UPDATE "documents_elementpath" SET "path" = ARRAY['{destination_id}'::uuid]::uuid[], "ordering" = 3 diff --git a/arkindex/sql_validation/element_move_without_child.sql b/arkindex/sql_validation/element_move_without_child.sql index 3645cc093a218abe0ea61c5c8758dff3afffebc0..b06caf357c5b6e5e7e8a9a7157429fa416df873f 100644 --- a/arkindex/sql_validation/element_move_without_child.sql +++ b/arkindex/sql_validation/element_move_without_child.sql @@ -60,14 +60,11 @@ SELECT EXISTS FROM parent_paths), (SELECT path FROM parent_paths - LIMIT 1) ; - -SELECT (MAX("documents_elementpath"."ordering") + 1) AS "max" -FROM "documents_elementpath" -INNER JOIN "documents_element" ON ("documents_elementpath"."element_id" = "documents_element"."id") -WHERE ("documents_elementpath"."path"[array_length("documents_elementpath"."path", 1)] = '{destination_id}'::uuid - AND "documents_elementpath"."path" && (ARRAY['{destination_id}'::uuid])::uuid[] - AND "documents_element"."type_id" = '{page_type_id}'::uuid); + LIMIT 1), + (SELECT COALESCE(MAX(ordering) + 1, 0) + FROM documents_elementpath + WHERE path @ > ARRAY['{destination_id}'::uuid] + AND path[array_length(path, 1)] = '{destination_id}'::uuid ) ; UPDATE "documents_elementpath" SET "path" = ARRAY['{destination_id}'::uuid]::uuid[], "ordering" = 3 diff --git a/arkindex/sql_validation/remove_child_last_parent.sql b/arkindex/sql_validation/remove_child_last_parent.sql index b35c23b5a31540367ab5f60957e38742086b2eb3..9dd30683ac5939138d87d59ff8b85a7763f0f5b1 100644 --- a/arkindex/sql_validation/remove_child_last_parent.sql +++ b/arkindex/sql_validation/remove_child_last_parent.sql @@ -14,17 +14,17 @@ WHERE element_id = '{B}'::uuid ; UPDATE documents_elementpath SET path = path[3:] -WHERE path @ > ARRAY['{Y}'::uuid, +WHERE path @ > ARRAY['{first_parent}'::uuid, '{A}'::uuid, '{B}'::uuid] - AND path[0:3] = ARRAY['{Y}'::uuid, + AND path[0:3] = ARRAY['{first_parent}'::uuid, '{A}'::uuid, '{B}'::uuid] ; UPDATE "documents_elementpath" SET "path" = '{{}}'::uuid[] WHERE ("documents_elementpath"."element_id" = '{B}'::uuid - AND "documents_elementpath"."path" = (ARRAY['{Y}'::uuid, + AND "documents_elementpath"."path" = (ARRAY['{first_parent}'::uuid, '{A}'::uuid])::uuid[]); DELETE