Repository: metabrainz/musicbrainz-server
Pull request #1659: MBS-8668: Block adding duplicates to a series
Author: login: reosarevok
Labels: name: Bug
description: Bugs that should be checked/fixed soonish
Created: 2020-08-20T06:21:31Z | Merged: 2025-04-03T23:00:09Z

### Fix MBS-8668

Elsewhere, we accept relationships as different if their link order varies. For series though, unless the number attribute is different, we probably don't want to have the same entity listed many times (and to the user is often not obvious at all that a link order even exists, especially if the series is set to automated order).

NB: this blocks adding new duplicates, but cases where the duplicates already exist will throw DuplicateViolation, which in the entity editor is hardcoded to give what is a completely unrelated error (see image below for the error, only difference is in the current version it won't show the green new rel line).

This is not only new with this code, it was already the case for any relationships that were already considered duplicate before - we're just not seeing it because the JS layer doesn't let us submit those duplicates. Not sure whether we should just purge the series rels duplicates from the DB if we merge this and otherwise ignore the issue, or throw a different exception for duplicate relationships (but using DuplicateViolation in Edit::Relationship::Edit does seem sensible in other cases). Or we could just remove the check completely from the Perl layer and just check it on JS.

Do we consider it safe to ignore link order for PART_OF_SERIES, or are there cases where this does matter?

![Screenshot from 2020-08-19 15-48-18](https://user-images.githubusercontent.com/1069224/90723999-70acdb80-e2c6-11ea-9cb8-b05ac2f52787.png)


Diff:
diff --git a/lib/MusicBrainz/Server/Constants.pm b/lib/MusicBrainz/Server/Constants.pm
index 2fcd78eee63..9ca283a91c9 100644
--- a/lib/MusicBrainz/Server/Constants.pm
+++ b/lib/MusicBrainz/Server/Constants.pm
@@ -75,7 +75,8 @@ our @EXPORT_OK = (
         $LIMIT_FOR_EDIT_LISTING
         $ARTIST_ARTIST_COLLABORATION
         $AMAZON_ASIN_LINK_TYPE_ID
-        %PART_OF_SERIES $PART_OF_AREA_LINK_TYPE $PART_OF_AREA_LINK_TYPE_ID
+        %PART_OF_SERIES @PART_OF_SERIES_LINK_TYPE_IDS
+        $PART_OF_AREA_LINK_TYPE $PART_OF_AREA_LINK_TYPE_ID
         $SERIES_ORDERING_TYPE_AUTOMATIC $SERIES_ORDERING_TYPE_MANUAL
         $ARTIST_RENAME_LINK_TYPE
         $LABEL_RENAME_LINK_TYPE
@@ -438,6 +439,15 @@ Readonly our %PART_OF_SERIES => (
     work            => 'b0d44366-cdf0-3acb-bee6-0f65a77a6ef0',
 );
 
+Readonly our @PART_OF_SERIES_LINK_TYPE_IDS => (
+    740, # recording
+    741, # release
+    742, # release group
+    743, # work
+    802, # event
+    996, # artist
+);
+
 Readonly our $AMAZON_ASIN_LINK_TYPE_ID => 77;
 
 Readonly our $PART_OF_AREA_LINK_TYPE => 'de7cc874-8b1b-3a05-8272-f3834c968fb7';
diff --git a/lib/MusicBrainz/Server/Data/Relationship.pm b/lib/MusicBrainz/Server/Data/Relationship.pm
index 81af9b1c728..576bb1585d0 100644
--- a/lib/MusicBrainz/Server/Data/Relationship.pm
+++ b/lib/MusicBrainz/Server/Data/Relationship.pm
@@ -20,6 +20,8 @@ use MusicBrainz::Server::Data::Series;
 use MusicBrainz::Server::Data::URL;
 use MusicBrainz::Server::Data::Work;
 use MusicBrainz::Server::Data::Utils qw(
+    contains_number
+    non_empty
     placeholders
     ref_to_type
     type_to_model
@@ -28,6 +30,7 @@ use MusicBrainz::Server::Constants qw(
     :direction
     %ENTITIES
     %ENTITIES_WITH_RELATIONSHIP_CREDITS
+    @PART_OF_SERIES_LINK_TYPE_IDS
     @RELATABLE_ENTITIES
 );
 use Scalar::Util qw( weaken );
@@ -823,8 +826,18 @@ sub exists {
 
     $self->_check_types($type0, $type1);
 
-    my @props = qw(entity0 entity1 link_order link);
-    my @values = @{$values}{qw(entity0_id entity1_id link_order)};
+    my @props = qw(entity0 entity1);
+    my @values = @{$values}{qw(entity0_id entity1_id)};
+
+    # For part of series, do not count link order as different
+    # if everything else is the same in order to avoid duplicate additions
+    # (different number attributes still allowed)
+    my $ignore_link_order = contains_number(\@PART_OF_SERIES_LINK_TYPE_IDS, $values->{link_type_id});
+
+    if (non_empty($values->{link_order}) && !$ignore_link_order) {
+        push @props, 'link_order';
+        push @values, $values->{link_order};
+    }
 
     my $link = $self->c->model('Link')->find({
         link_type_id => $values->{link_type_id},
@@ -835,6 +848,7 @@ sub exists {
     });
 
     return 0 unless $link;
+    push @props, 'link';
     push @values, $link;
 
     return $self->sql->select_single_value(
diff --git a/root/static/scripts/relationship-editor/utility/compareRelationships.js b/root/static/scripts/relationship-editor/utility/compareRelationships.js
index a72a387c62f..901b0e0100a 100644
--- a/root/static/scripts/relationship-editor/utility/compareRelationships.js
+++ b/root/static/scripts/relationship-editor/utility/compareRelationships.js
@@ -24,6 +24,7 @@ import compareDates, {
 import {areLinkAttrsEqual} from '../../common/utility/groupRelationships.js';
 import {memoizeWithDefault} from '../../common/utility/memoize.js';
 import {fixedWidthInteger} from '../../common/utility/strings.js';
+import {REL_STATUS_ADD, REL_STATUS_EDIT} from '../constants.js';
 import type {
   RelationshipStateT,
 } from '../types.js';
@@ -284,10 +285,15 @@ export default function compareRelationships(
   }
 
   const linkTypeId = a.linkTypeID;
+  const isPartOfSeries = PART_OF_SERIES_LINK_TYPE_IDS.includes(linkTypeId);
   const source = backward ? a.entity1 : a.entity0;
   const targetA = backward ? a.entity0 : a.entity1;
   const targetB = backward ? b.entity0 : b.entity1;
   const targetIdCmp = targetA.id - targetB.id;
+  const isAdd = a._status === REL_STATUS_ADD || b._status === REL_STATUS_ADD;
+  const isEdit = a._status === REL_STATUS_EDIT ||
+                 b._status === REL_STATUS_EDIT;
+  const isChangedPartOfSeries = isPartOfSeries && (isAdd || isEdit);
 
   if (__DEV__) {
     invariant(
@@ -311,7 +317,7 @@ export default function compareRelationships(
     source.entityType === 'series' &&
     source.orderingTypeID === SERIES_ORDERING_TYPE_AUTOMATIC &&
     linkTypeId != null &&
-    PART_OF_SERIES_LINK_TYPE_IDS.includes(linkTypeId)
+    isPartOfSeries
   ) {
     const seriesItemCmp = (
       /*
@@ -336,7 +342,7 @@ export default function compareRelationships(
     targetA.entityType === 'series' &&
     targetA.orderingTypeID === SERIES_ORDERING_TYPE_AUTOMATIC &&
     linkTypeId != null &&
-    PART_OF_SERIES_LINK_TYPE_IDS.includes(linkTypeId)
+    isPartOfSeries
   ) {
     const seriesItemCmp = (
       /*
@@ -356,7 +362,13 @@ export default function compareRelationships(
   }
 
   const linkOrderCmp = a.linkOrder - b.linkOrder;
-  if (linkOrderCmp) {
+  /*
+   * For part of series, do not count link order as different when adding
+   * if everything else is the same in order to avoid duplicate additions
+   * (different number attributes still allowed). Still show the rels
+   * separately when not adding so it is possible to edit them as neeeded.
+   */
+  if (linkOrderCmp && !(isChangedPartOfSeries)) {
     return linkOrderCmp;
   }
 
diff --git a/root/static/scripts/relationship-editor/utility/getRelationshipStatusName.js b/root/static/scripts/relationship-editor/utility/getRelationshipStatusName.js
index 9664d4394c4..cdb4f97bdad 100644
--- a/root/static/scripts/relationship-editor/utility/getRelationshipStatusName.js
+++ b/root/static/scripts/relationship-editor/utility/getRelationshipStatusName.js
@@ -8,6 +8,7 @@
  */
 
 import {
+  type RelationshipEditStatusT,
   REL_STATUS_ADD,
   REL_STATUS_EDIT,
   REL_STATUS_NOOP,
@@ -15,10 +16,10 @@ import {
 } from '../constants.js';
 import type {RelationshipStateT} from '../types.js';
 
-export default function getRelationshipStatusName(
-  relationship: RelationshipStateT,
+export function getStatusName(
+  status: RelationshipEditStatusT,
 ): string {
-  switch (relationship._status) {
+  switch (status) {
     case REL_STATUS_ADD:
       return 'add';
     case REL_STATUS_EDIT:
@@ -30,3 +31,9 @@ export default function getRelationshipStatusName(
   }
   return '';
 }
+
+export default function getRelationshipStatusName(
+  relationship: RelationshipStateT,
+): string {
+  return getStatusName(relationship._status);
+}
diff --git a/root/static/scripts/tests/relationship-editor/utility/compareRelationships.js b/root/static/scripts/tests/relationship-editor/utility/compareRelationships.js
index fa1a712c04e..34296932648 100644
--- a/root/static/scripts/tests/relationship-editor/utility/compareRelationships.js
+++ b/root/static/scripts/tests/relationship-editor/utility/compareRelationships.js
@@ -21,12 +21,21 @@ import {
   createRecordingObject,
   createSeriesObject,
 } from '../../../common/entity2.js';
+import {
+  REL_STATUS_ADD,
+  REL_STATUS_EDIT,
+  REL_STATUS_NOOP,
+  REL_STATUS_REMOVE,
+} from '../../../relationship-editor/constants.js';
 import compareRelationships
   from '../../../relationship-editor/utility/compareRelationships.js';
 import {
   exportLinkAttributeTypeInfo,
   exportLinkTypeInfo,
 } from '../../../relationship-editor/utility/exportTypeInfo.js';
+import {
+  getStatusName,
+} from '../../../relationship-editor/utility/getRelationshipStatusName.js';
 import {linkAttributeTypes, linkTypes} from '../../typeInfo.js';
 import {emptyRelationship} from '../constants.js';
 
@@ -243,7 +252,7 @@ test('compareRelationships: Time comparisons', function (t) {
 });
 
 test('compareRelationships: Series comparisons', function (t) {
-  t.plan(4);
+  t.plan(21);
 
   const recording = createRecordingObject({
     id: 1,
@@ -307,7 +316,62 @@ test('compareRelationships: Series comparisons', function (t) {
     'The same non-"part of series" relationship with different order attributes is seen as equal',
   );
 
-  const recordingSeriesPartOfRelOrder1 = {
+  const recordingSeriesPartOfRelWithLinkOrder1 = {
+    ...emptyRelationship,
+    entity0: recording,
+    entity1: series,
+    id: -1,
+    linkOrder: 1,
+    linkTypeID: 740, // Part of series
+  };
+
+  const recordingSeriesPartOfRelWithLinkOrder2 = {
+    ...recordingSeriesPartOfRelWithLinkOrder1,
+    linkOrder: 2,
+  };
+
+  t.ok(
+    compareRelationships(
+      recordingSeriesPartOfRelWithLinkOrder1,
+      recordingSeriesPartOfRelWithLinkOrder1,
+      false,
+    ) === 0,
+    'The exact same "part of series" relationship with the same link order is seen as equal',
+  );
+
+  const sameRelDifferentLinkOrderTests = [
+    [REL_STATUS_NOOP, REL_STATUS_NOOP, false],
+    [REL_STATUS_NOOP, REL_STATUS_ADD, true],
+    [REL_STATUS_NOOP, REL_STATUS_EDIT, true],
+    [REL_STATUS_NOOP, REL_STATUS_REMOVE, false],
+    [REL_STATUS_ADD, REL_STATUS_ADD, true],
+    [REL_STATUS_ADD, REL_STATUS_EDIT, true],
+    [REL_STATUS_ADD, REL_STATUS_REMOVE, true],
+    [REL_STATUS_EDIT, REL_STATUS_EDIT, true],
+    [REL_STATUS_EDIT, REL_STATUS_REMOVE, true],
+    [REL_STATUS_REMOVE, REL_STATUS_REMOVE, false],
+  ];
+
+  for (const [status1, status2, isEqual] of sameRelDifferentLinkOrderTests) {
+    const statusOrderings = [[status1, status2]];
+    if (status1 !== status2) {
+      statusOrderings.push([status2, status1]);
+    }
+    for (const [statusA, statusB] of statusOrderings) {
+      t.ok(
+        compareRelationships(
+          {...recordingSeriesPartOfRelWithLinkOrder1, _status: statusA},
+          {...recordingSeriesPartOfRelWithLinkOrder2, _status: statusB},
+          false,
+        ) === (isEqual ? 0 : -1),
+        'The exact same "part of series" relationship with a different ' +
+        'link order is' + (isEqual ? ' ' : ' not ') + 'seen as equal for ' +
+        '(' + getStatusName(statusA) + ', ' + getStatusName(statusB) + ')',
+      );
+    }
+  }
+
+  const recordingSeriesPartOfRelWithNumber1 = {
     ...emptyRelationship,
     attributes: attributesWithOrdering1,
     entity0: recording,
@@ -316,26 +380,26 @@ test('compareRelationships: Series comparisons', function (t) {
     linkTypeID: 740, // Part of series
   };
 
-  const recordingSeriesPartOfRelOrder2 = {
-    ...recordingSeriesPartOfRelOrder1,
+  const recordingSeriesPartOfRelWithNumber2 = {
+    ...recordingSeriesPartOfRelWithNumber1,
     attributes: attributesWithOrdering2,
   };
 
   t.ok(
     compareRelationships(
-      recordingSeriesPartOfRelOrder1,
-      recordingSeriesPartOfRelOrder1,
+      recordingSeriesPartOfRelWithNumber1,
+      recordingSeriesPartOfRelWithNumber1,
       false,
     ) === 0,
-    'The exact same "part of series" relationship is seen as equal',
+    'The exact same "part of series" relationship with the same number is seen as equal',
   );
 
   t.ok(
     compareRelationships(
-      recordingSeriesPartOfRelOrder1,
-      recordingSeriesPartOfRelOrder2,
+      recordingSeriesPartOfRelWithNumber1,
+      recordingSeriesPartOfRelWithNumber2,
       false,
     ) === -1,
-    'The same "part of series" relationship with different order attributes is not seen as equal, and smaller order sorts first',
+    'The same "part of series" relationship with a different number attribute is not seen as equal, and smaller order sorts first',
   );
 });
diff --git a/t/lib/t/MusicBrainz/Server/Data/Link.pm b/t/lib/t/MusicBrainz/Server/Data/Link.pm
index 8bce44cc3c0..266ba61f301 100644
--- a/t/lib/t/MusicBrainz/Server/Data/Link.pm
+++ b/t/lib/t/MusicBrainz/Server/Data/Link.pm
@@ -47,9 +47,9 @@ $link_id = $test->c->model('Link')->find_or_insert({
     attributes => [ map +{ type => { id => $_ } }, (1, 302) ],
 });
 $test->c->sql->commit;
-is($link_id, 5);
+is($link_id, 6);
 
-my $link = $test->c->model('Link')->get_by_id(5);
+my $link = $test->c->model('Link')->get_by_id(6);
 is_deeply($link->begin_date, { year => 2009 });
 is_deeply($link->end_date, { year => 2010 });
 
@@ -62,7 +62,7 @@ $link_id = $test->c->model('Link')->find_or_insert({
     attributes => [ map +{ type => { id => $_ } }, (1, 302) ],
 });
 $test->c->sql->commit;
-is($link_id, 5, 'find_or_insert() correctly re-uses a link with end date');
+is($link_id, 6, 'find_or_insert() correctly re-uses a link with end date');
 
 $test->c->sql->begin;
 $link_id = $test->c->model('Link')->find_or_insert({
diff --git a/t/lib/t/MusicBrainz/Server/Data/Relationship.pm b/t/lib/t/MusicBrainz/Server/Data/Relationship.pm
index 0750c9ed3cd..37c88c26c21 100644
--- a/t/lib/t/MusicBrainz/Server/Data/Relationship.pm
+++ b/t/lib/t/MusicBrainz/Server/Data/Relationship.pm
@@ -176,6 +176,40 @@ test q(Don't consider relationships with different link orders to be the same) =
     ok(!$exists, 'relationship with different link order is not the same');
 };
 
+test 'Consider part of series relationships with different link orders to be the same' => sub {
+    my $test = shift;
+    my $c = $test->c;
+
+    MusicBrainz::Server::Test->prepare_test_database($test->c, '+relationships');
+
+    my $exists = $c->model('Relationship')->exists('artist', 'series', {
+        entity0_id => 3,
+        entity1_id => 1,
+        link_order => 2,
+        link_type_id => 996,
+        attributes => [],
+    });
+
+    ok($exists, 'Part of series relationship with different link order is the same');
+};
+
+test q(Don't consider part of series relationships with different numbers to be the same) => sub {
+    my $test = shift;
+    my $c = $test->c;
+
+    MusicBrainz::Server::Test->prepare_test_database($test->c, '+relationships');
+
+    my $exists = $c->model('Relationship')->exists('artist', 'series', {
+        entity0_id => 3,
+        entity1_id => 1,
+        link_order => 2,
+        link_type_id => 996,
+        attributes => [{ type => { id => 788 }, text_value => 'oh look a number' }],
+    });
+
+    ok(!$exists, 'Part of series relationship with different number is not the same');
+};
+
 test 'Entity credits are merged' => sub {
     my $test = shift;
     my $c = $test->c;
@@ -439,7 +473,7 @@ is(scalar($artist1->all_relationships), 1, 'allow all rels');
 
 $rel = $artist1->relationships->[0];
 is($rel->id, 4);
-is($rel->link->id, 5);
+is($rel->link->id, 6);
 is_deeply($rel->link->begin_date, { year => 2008, month => 2, day => 3 });
 is_deeply($rel->link->end_date, { year => 2008, month => 2, day => 8 });
 is($rel->phrase, 'additional <a href="/instrument/63021302-86cd-4aee-80df-2270d54f4978">guitar</a> and ' .
@@ -463,7 +497,7 @@ is(scalar($artist1->all_relationships), 1);
 
 $rel = $artist1->relationships->[0];
 is($rel->id, 4);
-is($rel->link->id, 6);
+is($rel->link->id, 7);
 is_deeply($rel->link->begin_date, { });
 is_deeply($rel->link->end_date, { });
 is($rel->phrase,
diff --git a/t/sql/relationships.sql b/t/sql/relationships.sql
index bdaa53b7b84..9d93a5a40bd 100644
--- a/t/sql/relationships.sql
+++ b/t/sql/relationships.sql
@@ -3,7 +3,8 @@ SET client_min_messages TO 'warning';
 INSERT INTO link (id, link_type, attribute_count) VALUES (1, 148, 1),
                                                          (2, 148, 2),
                                                          (3, 743, 1),
-                                                         (4, 148, 1);
+                                                         (4, 148, 1),
+                                                         (5, 996, 0);
 
 INSERT INTO link_attribute (link, attribute_type) VALUES (1, 229),
                                                          (2, 1),
@@ -19,7 +20,8 @@ INSERT INTO link_attribute_credit (link, attribute_type, credited_as)
 
 INSERT INTO artist (id, gid, name, sort_name)
     VALUES (1, 'e2a083a9-9942-4d6e-b4d2-8397320b95f7', 'Artist 1', 'Artist 1'),
-           (2, '2fed031c-0e89-406e-b9f0-3d192637907a', 'Artist 2', 'Artist 2');
+           (2, '2fed031c-0e89-406e-b9f0-3d192637907a', 'Artist 2', 'Artist 2'),
+           (3, '2fed031c-0e89-406e-b9f0-3d192637907b', 'Artist 3', 'Artist 3');
 
 INSERT INTO artist_credit (id, name, artist_count, gid)
     VALUES (1, 'Artist 1', 1, '949a7fd5-fe73-3e8f-922e-01ff4ca958f7');
@@ -30,6 +32,11 @@ INSERT INTO recording (id, gid, name, artist_credit)
     VALUES (1, '99caac80-72e4-11de-8a39-0800200c9a66', 'Track 1', 1),
            (2, 'a12bb640-72e4-11de-8a39-0800200c9a66', 'Track 2', 1);
 
+INSERT INTO series (id, gid, name, type, ordering_type)
+    VALUES (1, 'a8749d0c-4a5a-4403-97c5-f6cd018f8e6a', 'Test Artist Series', 13, 1);
+
 INSERT INTO l_artist_recording (id, link, entity0, entity1) VALUES (1, 1, 1, 1);
 INSERT INTO l_artist_recording (id, link, entity0, entity1, edits_pending) VALUES (2, 1, 2, 1, 1);
 INSERT INTO l_artist_recording (id, link, entity0, entity1) VALUES (3, 2, 1, 2);
+
+INSERT INTO l_artist_series (id, link, entity0, entity1, link_order) VALUES (1, 5, 3, 1, 1);


Reviews:
user: login: yvanzo
body: I guess the DuplicateViolation was intended for newly created duplicate entity, not for duplicate relationship. It seems worth to have two distinct errors then.

Pre-existing duplicates should probably not block editing a series, just like we intentionally don’t block editing an entity if an existing URL relationship is not valid anymore.

The check should still be made on the back-end as data validation is one of its main purposes.
state: CHANGES_REQUESTED
author_association: CONTRIBUTOR
submitted_at: 2020-09-16T07:50:03Z
user: login: mwiencek
state: COMMENTED
author_association: MEMBER
submitted_at: 2021-05-20T14:00:49Z
user: login: reosarevok
state: COMMENTED
author_association: MEMBER
submitted_at: 2021-05-20T18:13:11Z
user: login: reosarevok
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-04T14:43:36Z
user: login: mwiencek
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-01T00:27:48Z
user: login: reosarevok
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-01T08:37:36Z
user: login: reosarevok
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-01T08:51:09Z
user: login: reosarevok
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-01T09:06:40Z
user: login: mwiencek
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-01T15:10:54Z
user: login: reosarevok
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-01T15:20:41Z
user: login: mwiencek
body: This looks good to me overall, though it's kinda subtle behavior so it seems like the kind of thing that should have at least one test on the Perl and JS side:
 * We have an existing test for "Don't consider relationships with different link orders to be the same" in t/lib/t/MusicBrainz/Server/Data/Relationship.pm which we could copy and adapt for the series case.
 * On the JS side we have a "compareRelationships: Series comparisons" test in root/static/scripts/tests/relationship-editor/utility/compareRelationships.js which we could just amend with one additional case.
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-01T15:27:18Z
user: login: reosarevok
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-02T12:59:35Z

Review comments:
diff_hunk: @@ -580,8 +581,18 @@ sub exists {
 
     $self->_check_types($type0, $type1);
 
-    my @props = qw(entity0 entity1 link_order link);
-    my @values = @{$values}{qw(entity0_id entity1_id link_order)};
+    my @props = qw(entity0 entity1);
+    my @values = @{$values}{qw(entity0_id entity1_id)};
+
+    # For part of series, do not count link order as different
+    # if everything else is the same in order to avoid duplicate additions
+    # (different number attributes still allowed)
+    my $ignore_link_order = grep { $_ eq $values->{link_type_gid} } values %PART_OF_SERIES;
path: lib/MusicBrainz/Server/Data/Relationship.pm
user: login: mwiencek
body: We might want to maintain a constant for this if it's not inherent to series; stuff like recording-recording compilation/DJ-mix relationships probably make sense to ignore too? (Ideally this type of thing might be stored in the schema...)

```Perl
our %NO_DUPLICATE_LINK_ORDERS = map {
    $_ => 1,
} (
    values %PART_OF_SERIES,
    # other rels here
);
created_at: 2021-05-20T13:59:39Z
author_association: MEMBER
diff_hunk: @@ -580,8 +581,18 @@ sub exists {
 
     $self->_check_types($type0, $type1);
 
-    my @props = qw(entity0 entity1 link_order link);
-    my @values = @{$values}{qw(entity0_id entity1_id link_order)};
+    my @props = qw(entity0 entity1);
+    my @values = @{$values}{qw(entity0_id entity1_id)};
+
+    # For part of series, do not count link order as different
+    # if everything else is the same in order to avoid duplicate additions
+    # (different number attributes still allowed)
+    my $ignore_link_order = grep { $_ eq $values->{link_type_gid} } values %PART_OF_SERIES;
+
+    if ($values->{link_order} && !$ignore_link_order) {
path: lib/MusicBrainz/Server/Data/Relationship.pm
user: login: mwiencek
body: Should check `defined` to handle 0, right? Or `non_empty`
```suggestion
    if (defined $values->{link_order} && !$ignore_link_order) {
```
created_at: 2021-05-20T14:00:25Z
author_association: MEMBER
diff_hunk: @@ -178,12 +178,18 @@ const RE = MB.relationshipEditor = MB.relationshipEditor || {};
                 var other = relationships[i];
 
                 if (rel !== other && rel.isDuplicate(other)) {
-                    var obj = _.omit(rel.editData(), "id");
-
-                    obj.begin_date = mergeDates(rel.begin_date, other.begin_date);
-                    obj.end_date = mergeDates(rel.end_date, other.end_date);
+                    /*
+                     * If it is a duplicate part of series,
+                     * just ignore it completely, otherwise merge it.
+                     */
+                    if (!rel.isSeriesPart()) {
+                        var obj = _.omit(rel.editData(), "id");
path: root/static/scripts/relationship-editor/common/entity.js
user: login: mwiencek
body: ```suggestion
                        const obj = _.omit(rel.editData(), "id");
```
created_at: 2021-05-20T14:00:39Z
author_association: MEMBER
diff_hunk: @@ -580,8 +581,18 @@ sub exists {
 
     $self->_check_types($type0, $type1);
 
-    my @props = qw(entity0 entity1 link_order link);
-    my @values = @{$values}{qw(entity0_id entity1_id link_order)};
+    my @props = qw(entity0 entity1);
+    my @values = @{$values}{qw(entity0_id entity1_id)};
+
+    # For part of series, do not count link order as different
+    # if everything else is the same in order to avoid duplicate additions
+    # (different number attributes still allowed)
+    my $ignore_link_order = grep { $_ eq $values->{link_type_gid} } values %PART_OF_SERIES;
path: lib/MusicBrainz/Server/Data/Relationship.pm
user: login: reosarevok
body: Those are only done with arrows though, so you can't get a duplicate order, can you?
created_at: 2021-05-20T18:13:11Z
author_association: MEMBER
diff_hunk: @@ -580,8 +581,18 @@ sub exists {
 
     $self->_check_types($type0, $type1);
 
-    my @props = qw(entity0 entity1 link_order link);
-    my @values = @{$values}{qw(entity0_id entity1_id link_order)};
+    my @props = qw(entity0 entity1);
+    my @values = @{$values}{qw(entity0_id entity1_id)};
+
+    # For part of series, do not count link order as different
+    # if everything else is the same in order to avoid duplicate additions
+    # (different number attributes still allowed)
+    my $ignore_link_order = grep { $_ eq $values->{link_type_gid} } values %PART_OF_SERIES;
path: lib/MusicBrainz/Server/Data/Relationship.pm
user: login: reosarevok
body: > "stuff like recording-recording compilation/DJ-mix relationships probably make sense to ignore too"

Actually if the same recording is used twice in a DJ-mix or whatnot, that would seem legit - maybe better to stick to series only when ignoring link orders.
created_at: 2025-03-04T14:43:35Z
author_association: MEMBER
diff_hunk: @@ -778,8 +780,18 @@ sub exists {
 
     $self->_check_types($type0, $type1);
 
-    my @props = qw(entity0 entity1 link_order link);
-    my @values = @{$values}{qw(entity0_id entity1_id link_order)};
+    my @props = qw(entity0 entity1);
+    my @values = @{$values}{qw(entity0_id entity1_id)};
+
+    # For part of series, do not count link order as different
+    # if everything else is the same in order to avoid duplicate additions
+    # (different number attributes still allowed)
+    my $ignore_link_order = grep { $_ eq $values->{link_type_gid} } values %PART_OF_SERIES;
path: lib/MusicBrainz/Server/Data/Relationship.pm
user: login: mwiencek
body: I see you pass `link_type_gid` to here in two places below, but if I search for `model('Relationship')->exists`, there are 5 calls (including one in the tests). So it seems kinda unclear whether it's required. Would it make sense to just add a constant for the part-of-series row IDs instead, so that a gid isn't needed?
created_at: 2025-04-01T00:27:47Z
author_association: MEMBER
diff_hunk: @@ -778,8 +780,18 @@ sub exists {
 
     $self->_check_types($type0, $type1);
 
-    my @props = qw(entity0 entity1 link_order link);
-    my @values = @{$values}{qw(entity0_id entity1_id link_order)};
+    my @props = qw(entity0 entity1);
+    my @values = @{$values}{qw(entity0_id entity1_id)};
+
+    # For part of series, do not count link order as different
+    # if everything else is the same in order to avoid duplicate additions
+    # (different number attributes still allowed)
+    my $ignore_link_order = grep { $_ eq $values->{link_type_gid} } values %PART_OF_SERIES;
path: lib/MusicBrainz/Server/Data/Relationship.pm
user: login: reosarevok
body: You mean just use `link_type_id` and have something like `%PART_OF_SERIES_ROW_IDS` that we look at instead? If that seems better, I can do that.

I'm confused about the "So it seems kinda unclear whether it's required" bit - if what is required? :) We need to look at this in here in some way to avoid a `MusicBrainz::Server::Edit::Exceptions::NoChanges->throw`, I understand.
created_at: 2025-04-01T08:37:36Z
author_association: MEMBER
diff_hunk: @@ -778,8 +780,18 @@ sub exists {
 
     $self->_check_types($type0, $type1);
 
-    my @props = qw(entity0 entity1 link_order link);
-    my @values = @{$values}{qw(entity0_id entity1_id link_order)};
+    my @props = qw(entity0 entity1);
+    my @values = @{$values}{qw(entity0_id entity1_id)};
+
+    # For part of series, do not count link order as different
+    # if everything else is the same in order to avoid duplicate additions
+    # (different number attributes still allowed)
+    my $ignore_link_order = grep { $_ eq $values->{link_type_gid} } values %PART_OF_SERIES;
path: lib/MusicBrainz/Server/Data/Relationship.pm
user: login: reosarevok
body: Seems we don't use the Perl `%PART_OF_SERIES` anywhere else, not sure if it should in that case be removed or what :) 
created_at: 2025-04-01T08:51:09Z
author_association: MEMBER
diff_hunk: @@ -778,8 +780,18 @@ sub exists {
 
     $self->_check_types($type0, $type1);
 
-    my @props = qw(entity0 entity1 link_order link);
-    my @values = @{$values}{qw(entity0_id entity1_id link_order)};
+    my @props = qw(entity0 entity1);
+    my @values = @{$values}{qw(entity0_id entity1_id)};
+
+    # For part of series, do not count link order as different
+    # if everything else is the same in order to avoid duplicate additions
+    # (different number attributes still allowed)
+    my $ignore_link_order = grep { $_ eq $values->{link_type_gid} } values %PART_OF_SERIES;
path: lib/MusicBrainz/Server/Data/Relationship.pm
user: login: reosarevok
body: I pushed a commit changing to use row ids - tested quickly by disabling the JS check, seems to work fine.
created_at: 2025-04-01T09:06:40Z
author_association: MEMBER
diff_hunk: @@ -778,8 +780,18 @@ sub exists {
 
     $self->_check_types($type0, $type1);
 
-    my @props = qw(entity0 entity1 link_order link);
-    my @values = @{$values}{qw(entity0_id entity1_id link_order)};
+    my @props = qw(entity0 entity1);
+    my @values = @{$values}{qw(entity0_id entity1_id)};
+
+    # For part of series, do not count link order as different
+    # if everything else is the same in order to avoid duplicate additions
+    # (different number attributes still allowed)
+    my $ignore_link_order = grep { $_ eq $values->{link_type_gid} } values %PART_OF_SERIES;
path: lib/MusicBrainz/Server/Data/Relationship.pm
user: login: mwiencek
body: > I'm confused about the "So it seems kinda unclear whether it's required" bit - if what is required? :) We need to look at this in here in some way to avoid a MusicBrainz::Server::Edit::Exceptions::NoChanges->throw, I understand.

I meant whether the `link_type_gid` was required, since you weren't passing it in all cases. (For example, maybe because the places it wasn't being passed never dealt with series relationships.) But I prefer we always perform this check (hence switching to the row IDs).

Now I'm a bit confused: isn't this patch intended to cause *more* `NoChanges` exceptions to be thrown? (Since `NoChanges` == this is a duplicate.)
created_at: 2025-04-01T15:10:54Z
author_association: MEMBER
diff_hunk: @@ -778,8 +780,18 @@ sub exists {
 
     $self->_check_types($type0, $type1);
 
-    my @props = qw(entity0 entity1 link_order link);
-    my @values = @{$values}{qw(entity0_id entity1_id link_order)};
+    my @props = qw(entity0 entity1);
+    my @values = @{$values}{qw(entity0_id entity1_id)};
+
+    # For part of series, do not count link order as different
+    # if everything else is the same in order to avoid duplicate additions
+    # (different number attributes still allowed)
+    my $ignore_link_order = grep { $_ eq $values->{link_type_gid} } values %PART_OF_SERIES;
path: lib/MusicBrainz/Server/Data/Relationship.pm
user: login: reosarevok
body: Yeah, I guess that's what I meant :D Anyway, now I got your point that I wasn't passing the `gid` everywhere earlier, thanks :)
created_at: 2025-04-01T15:20:41Z
author_association: MEMBER
diff_hunk: @@ -823,8 +825,18 @@ sub exists {
 
     $self->_check_types($type0, $type1);
 
-    my @props = qw(entity0 entity1 link_order link);
-    my @values = @{$values}{qw(entity0_id entity1_id link_order)};
+    my @props = qw(entity0 entity1);
+    my @values = @{$values}{qw(entity0_id entity1_id)};
+
+    # For part of series, do not count link order as different
+    # if everything else is the same in order to avoid duplicate additions
+    # (different number attributes still allowed)
+    my $ignore_link_order = grep { $_ eq $values->{link_type_id} } values %PART_OF_SERIES_ROW_IDS;
path: lib/MusicBrainz/Server/Data/Relationship.pm
user: login: mwiencek
body: Should we just store them as a list like `PART_OF_SERIES_LINK_TYPE_IDS` in root/static/scripts/common/constants.js so that we don't need to convert it via `values`? (At least until we have a use for looking them up by entity type.) I guess we could also use `contains_number` from `Data::Utils`, then. :)
created_at: 2025-04-01T15:22:47Z
author_association: MEMBER
diff_hunk: @@ -823,8 +825,18 @@ sub exists {
 
     $self->_check_types($type0, $type1);
 
-    my @props = qw(entity0 entity1 link_order link);
-    my @values = @{$values}{qw(entity0_id entity1_id link_order)};
+    my @props = qw(entity0 entity1);
+    my @values = @{$values}{qw(entity0_id entity1_id)};
+
+    # For part of series, do not count link order as different
+    # if everything else is the same in order to avoid duplicate additions
+    # (different number attributes still allowed)
+    my $ignore_link_order = grep { $_ eq $values->{link_type_id} } values %PART_OF_SERIES_ROW_IDS;
path: lib/MusicBrainz/Server/Data/Relationship.pm
user: login: reosarevok
body: Oh, I didn't realize we were already doing it that way in JS. Then we might as well, sure.
created_at: 2025-04-02T12:59:35Z
author_association: MEMBER

PR comments:
user: login: reosarevok
created_at: 2023-01-16T10:44:13Z
body: Testing this with the new relationship editor, the JS part is no longer needed because the new editor already detects these as duplicates and blocks them.

The Perl part might be worth keeping in case the JS check fails / gets bypassed.

For some reason, the exception message is not being shown anymore though - in fact `/series/476798b7-12ae-4237-9e2f-e2d4d6fab5f2` (with a duplicate release of catno 469 202-2) does not show any error message at all when trying to edit it.
author_association: MEMBER
user: login: mwiencek
created_at: 2023-02-10T05:41:41Z
body: > Testing this with the new relationship editor, the JS part is no longer needed because the new editor already detects these as duplicates and blocks them.

I can still add duplicates without any UI error in the beta branch, but only one duplicate per entity (same as MBS-12838 describes).

1. Add a new part to any automatic series, with a number set (from the series edit page)
2. Submit the edit
3. Reload the series edit page and add the exact same relationship with the same number attributes
4. It lets you submit the edit

If I try to repeat steps 3 & 4 again, it doesn't let me (it finally detects the new relationship as a duplicate of the previous two, somehow).

So it seems like some UI changes must be needed still.
author_association: MEMBER
user: login: reosarevok
created_at: 2023-02-10T09:04:19Z
body: Hmm, ok, I guess I was just testing adding the same rel twice, but not adding an existing rel :) I'll look into it.
author_association: MEMBER
user: login: reosarevok
created_at: 2025-04-02T14:02:07Z
body: Added the suggested tests. Reworded the existing JS ones a bit since they were confusingly mentioning "order" when they meant "number" (so, "ordering attribute").
author_association: MEMBER
user: login: mwiencek
created_at: 2025-04-02T16:54:32Z
body: It seems we're also lacking a JS check for the case where you edit an existing relationship to have the same number as another. The Perl still blocks the addition with a `DuplicateViolation` exception, but there's otherwise no error message when the form is reloaded (with the dupe relationship still being visible).

Ideally the relationship dialog would block the edit to begin with. I guess the `!(isPartOfSeries && isAdd)` check should also include edits, though it will have the effect of blocking any edits to the relationship that don't also fix the dupe number, IIUC.
author_association: MEMBER