Repository: biolab/orange3

Issue:
[ENH] OWMerge : Chose multiple variables in the 'where' condition
##### Expected behavior

Being able to merge choosing multiple grouping variables. Maybe using a VariableList instead of a combobox

##### Actual behavior

At the moment only one variable can be chosen to merge


##### Additional info (worksheets, data, screenshots, ...)

Example
![merge](https://cloud.githubusercontent.com/assets/6637618/24093856/3cc1bb72-0d56-11e7-90b7-db85905f4698.png)





Discussion:
What if variables don't have matching names?
Just display an error. 😆 
They may have different names. Currently you can match sex with gender.
I know, I know. I don't think there's really a good solution to this. Only perhaps adding the fourth option 'Merge by several variables' and having the VariableList there, while keeping combo boxes for the rest. VariableList would then require matching names (combos wouldn't).
Multiple combo boxes. Similar as, say, line edits in Create class.

Resolving patch:
diff --git a/Orange/data/table.py b/Orange/data/table.py
--- a/Orange/data/table.py
+++ b/Orange/data/table.py
@@ -295,7 +295,8 @@ def from_table(cls, domain, source, row_indices=...):
 
         global _conversion_cache
 
-        def get_columns(row_indices, src_cols, n_rows, dtype=np.float64, is_sparse=False):
+        def get_columns(row_indices, src_cols, n_rows, dtype=np.float64,
+                        is_sparse=False, variables=[]):
             if not len(src_cols):
                 if is_sparse:
                     return sp.csr_matrix((n_rows, 0), dtype=source.X.dtype)
@@ -331,7 +332,7 @@ def get_columns(row_indices, src_cols, n_rows, dtype=np.float64, is_sparse=False
             shared_cache = _conversion_cache
             for i, col in enumerate(src_cols):
                 if col is None:
-                    a[:, i] = Unknown
+                    a[:, i] = variables[i].Unknown
                 elif not isinstance(col, Integral):
                     if isinstance(col, SharedComputeValue):
                         if (id(col.compute_shared), id(source)) not in shared_cache:
@@ -394,19 +395,22 @@ def get_columns(row_indices, src_cols, n_rows, dtype=np.float64, is_sparse=False
                 self.domain = domain
                 conversion = domain.get_conversion(source.domain)
                 self.X = get_columns(row_indices, conversion.attributes, n_rows,
-                                     is_sparse=conversion.sparse_X)
+                                     is_sparse=conversion.sparse_X,
+                                     variables=domain.attributes)
                 if self.X.ndim == 1:
                     self.X = self.X.reshape(-1, len(self.domain.attributes))
 
                 self.Y = get_columns(row_indices, conversion.class_vars, n_rows,
-                                     is_sparse=conversion.sparse_Y)
+                                     is_sparse=conversion.sparse_Y,
+                                     variables=domain.class_vars)
 
                 dtype = np.float64
                 if any(isinstance(var, StringVariable) for var in domain.metas):
                     dtype = np.object
                 self.metas = get_columns(row_indices, conversion.metas,
                                          n_rows, dtype,
-                                         is_sparse=conversion.sparse_metas)
+                                         is_sparse=conversion.sparse_metas,
+                                         variables=domain.metas)
                 if self.metas.ndim == 1:
                     self.metas = self.metas.reshape(-1, len(self.domain.metas))
                 if source.has_weights():
diff --git a/Orange/widgets/data/owmergedata.py b/Orange/widgets/data/owmergedata.py
--- a/Orange/widgets/data/owmergedata.py
+++ b/Orange/widgets/data/owmergedata.py
@@ -1,25 +1,171 @@
-from enum import IntEnum
-from itertools import chain, product, tee
-
-from AnyQt.QtWidgets import QApplication, QStyle, QSizePolicy
+from collections import namedtuple
+from itertools import chain, product
 
 import numpy as np
 
+from AnyQt.QtCore import Qt, QModelIndex, pyqtSignal as Signal
+from AnyQt.QtWidgets import QWidget, QLabel, QComboBox, QPushButton, \
+    QVBoxLayout, QHBoxLayout
+
 import Orange
-from Orange.data import StringVariable, ContinuousVariable
+from Orange.data import StringVariable, ContinuousVariable, Variable
 from Orange.data.util import hstack
-from Orange.widgets import widget, gui, settings
-from Orange.widgets.utils import itemmodels
+from Orange.widgets import widget, gui
+from Orange.widgets.settings import Setting
+from Orange.widgets.utils.itemmodels import DomainModel
 from Orange.widgets.utils.sql import check_sql_input
 from Orange.widgets.utils.widgetpreview import WidgetPreview
-from Orange.widgets.widget import Input, Output
-
-
-class MergeType(IntEnum):
-    LEFT_JOIN, INNER_JOIN, OUTER_JOIN = 0, 1, 2
-
-INSTANCEID = "Source position (index)"
-INDEX = "Position (index)"
+from Orange.widgets.widget import Input, Output, Msg
+
+INSTANCEID = "Instance id"
+INDEX = "Row index"
+
+
+class ConditionBox(QWidget):
+    vars_changed = Signal(list)
+
+    RowItems = namedtuple(
+        "RowItems",
+        ("pre_label", "left_combo", "in_label", "right_combo",
+         "remove_button", "add_button"))
+
+    def __init__(self, parent, model_left, model_right, pre_label, in_label):
+        super().__init__(parent)
+        self.model_left = model_left
+        self.model_right = model_right
+        self.pre_label, self.in_label = pre_label, in_label
+        self.rows = []
+        self.setLayout(QVBoxLayout())
+        self.layout().setContentsMargins(0, 0, 0, 0)
+        self.layout().setSpacing(0)
+        self.setMouseTracking(True)
+
+    def add_row(self):
+        def sync_combos():
+            combo = self.sender()
+            index = combo.currentIndex()
+            model = combo.model()
+
+            other = ({row_items.left_combo, row_items.right_combo}
+                     - {combo}).pop()
+            other_index = other.currentIndex()
+            other_model = other.model()
+
+            if 0 <= index < len(combo.model()):
+                var = model[index]
+                if isinstance(var, str):
+                    other.setCurrentText(model[index])
+                elif isinstance(other_model[other_index], str):
+                    for other_var in other_model:
+                        if isinstance(other_var, Variable) \
+                                and var.name == other_var.name \
+                                and (type(other_var) is type(var)
+                                     or (not var.is_continuous
+                                         and not other_var.is_continuous)):
+                            other.setCurrentText(var.name)
+                            break
+
+            self.emit_list()
+
+        def get_combo(model):
+            combo = QComboBox(self)
+            combo.setModel(model)
+            # We use signal activated because it is triggered only on user
+            # interaction, not programmatically.
+            combo.activated.connect(sync_combos)
+            return combo
+
+        def get_button(label, callback):
+            button = QPushButton(label, self)
+            button.setFlat(True)
+            button.setFixedWidth(12)
+            button.clicked.connect(callback)
+            return button
+
+        row = self.layout().count()
+        row_items = self.RowItems(
+            QLabel("and" if row else self.pre_label),
+            get_combo(self.model_left),
+            QLabel(self.in_label),
+            get_combo(self.model_right),
+            get_button("×", self.on_remove_row),
+            get_button("+", self.on_add_row)
+        )
+        layout = QHBoxLayout()
+        layout.setSpacing(10)
+        self.layout().addLayout(layout)
+        layout.addStretch(10)
+        for item in row_items:
+            layout.addWidget(item)
+        self.rows.append(row_items)
+        self._reset_buttons()
+
+    def remove_row(self, row):
+        self.layout().takeAt(self.rows.index(row))
+        self.rows.remove(row)
+        for item in row:
+            item.deleteLater()
+        self._reset_buttons()
+
+    def on_add_row(self, _):
+        self.add_row()
+        self.emit_list()
+
+    def on_remove_row(self):
+        if len(self.rows) == 1:
+            return
+        button = self.sender()
+        for row in self.rows:
+            if button is row.remove_button:
+                self.remove_row(row)
+                break
+        self.emit_list()
+
+    def _reset_buttons(self):
+        def endis(button, enable, text):
+            button.setEnabled(enable)
+            button.setText(text if enable else "")
+
+        self.rows[0].pre_label.setText(self.pre_label)
+        single_row = len(self.rows) == 1
+        endis(self.rows[0].remove_button, not single_row, "×")
+        endis(self.rows[-1].add_button, True, "+")
+        if not single_row:
+            endis(self.rows[-2].add_button, False, "")
+
+    def current_state(self):
+        def get_var(model, combo):
+            return model[combo.currentIndex()]
+
+        return [(get_var(self.model_left, row.left_combo),
+                 get_var(self.model_right, row.right_combo))
+                for row in self.rows]
+
+    def set_state(self, values):
+        while len(self.rows) > len(values):
+            self.remove_row(self.rows[0])
+        while len(self.rows) < len(values):
+            self.add_row()
+        for (val_left, val_right), row in zip(values, self.rows):
+            row.left_combo.setCurrentIndex(self.model_left.indexOf(val_left))
+            row.right_combo.setCurrentIndex(self.model_right.indexOf(val_right))
+
+    def emit_list(self):
+        self.vars_changed.emit(self.current_state())
+
+
+class DomainModelWithTooltips(DomainModel):
+    def data(self, index, role=Qt.DisplayRole):
+        if role == Qt.ToolTipRole \
+                and isinstance(index, QModelIndex) and index.isValid():
+            if index.row() == 0:
+                return "Match rows sequentially"
+            if index.row() == 1:
+                return "Re-match rows from tables obtained from the same " \
+                       "source,\n" \
+                       "e.g. data from the same file that was split within " \
+                       "the workflow."
+        return super().data(index, role)
 
 
 class OWMergeData(widget.OWWidget):
@@ -38,38 +184,67 @@ class Outputs:
                       Orange.data.Table,
                       replaces=["Merged Data A+B", "Merged Data B+A", "Merged Data"])
 
-    attr_augment_data = settings.Setting('', schema_only=True)
-    attr_augment_extra = settings.Setting('', schema_only=True)
-    attr_merge_data = settings.Setting('', schema_only=True)
-    attr_merge_extra = settings.Setting('', schema_only=True)
-    attr_combine_data = settings.Setting('', schema_only=True)
-    attr_combine_extra = settings.Setting('', schema_only=True)
-    merging = settings.Setting(0)
-    auto_apply = settings.Setting(True)
+    LeftJoin, InnerJoin, OuterJoin = range(3)
+    OptionNames = ("Append columns from Extra data",
+                   "Find matching pairs of rows",
+                   "Concatenate tables")
+    OptionDescriptions = (
+        "The first table may contain, for instance, city names,\n"
+        "and the second would be a list of cities and their coordinates.\n"
+        "Columns with coordinates would then be appended to the output.",
+
+        "Input tables contain different features describing the same data "
+        "instances.\n"
+        "Output contains matched instances. Rows without matches are removed.",
+
+        "Input tables contain different features describing the same data "
+        "instances.\n"
+        "Output contains all instances. Data from merged instances is "
+        "merged into single rows."
+    )
+
+    UserAdviceMessages = [
+        widget.Message(
+            "Confused about merging options?\nSee the tooltips!",
+            "merging_types")]
+
+    attr_pairs = Setting(None, schema_only=True)
+    merging = Setting(LeftJoin)
+    auto_apply = Setting(True)
+    settings_version = 1
 
     want_main_area = False
     resizing_enabled = False
 
     class Warning(widget.OWWidget.Warning):
-        duplicate_names = widget.Msg("Duplicate variable names in output.")
-        non_unique_variables = widget.Msg(
-            "Columns with non-unique values can't be used for matching.\n"
-            "The second variable must uniquely define the row in the second "
-            "table. Variables with non-unique values ({}) "
-            "are thus unsuitable as keys for merging."
-        )
+        duplicate_names = Msg("Duplicate variable names in output.")
+
+    class Error(widget.OWWidget.Error):
+        matching_numeric_with_nonnum = Msg(
+            "Numeric and non-numeric columns ({} and {}) can't be matched.")
+        matching_index_with_sth = Msg("Row index cannot by matched with {}.")
+        matching_id_with_sth = Msg("Instance cannot by matched with {}.")
+        nonunique_left = Msg(
+            "Some combinations of values on the left appear in multiple rows.\n"
+            "For this type of merging, every possible combination of values "
+            "on the left should appear at most once.")
+        nonunique_right = Msg(
+            "Some combinations of values on the right appear in multiple rows."
+            "\n"
+            "Every possible combination of values on the right should appear "
+            "at most once.")
 
     def __init__(self):
         super().__init__()
 
         self.data = None
         self.extra_data = None
-        self.extra_data = None
 
-        self.model = itemmodels.VariableListModel()
-        self.model_unique_with_id = itemmodels.VariableListModel()
-        self.extra_model_unique = itemmodels.VariableListModel()
-        self.extra_model_unique_with_id = itemmodels.VariableListModel()
+        content = [
+            INDEX, INSTANCEID,
+            DomainModel.ATTRIBUTES, DomainModel.CLASSES, DomainModel.METAS]
+        self.model = DomainModelWithTooltips(content)
+        self.extra_model = DomainModelWithTooltips(content)
 
         box = gui.hBox(self.controlArea, box=None)
         self.infoBoxData = gui.label(
@@ -77,166 +252,121 @@ def __init__(self):
         self.infoBoxExtraData = gui.label(
             box, self, self.dataInfoText(None), box="Extra Data")
 
-        grp = gui.radioButtonsInBox(
+        grp = gui.radioButtons(
             self.controlArea, self, "merging", box="Merging",
+            btnLabels=self.OptionNames, tooltips=self.OptionDescriptions,
             callback=self.change_merging)
-        self.attr_boxes = []
-
-        radio_width = \
-            QApplication.style().pixelMetric(QStyle.PM_ExclusiveIndicatorWidth)
-
-        def add_option(label, pre_label, between_label,
-                       merge_type, model, extra_model):
-            gui.appendRadioButton(grp, label)
-            vbox = gui.vBox(grp)
-            box = gui.hBox(vbox)
-            box.layout().addSpacing(radio_width)
-            self.attr_boxes.append(box)
-            gui.widgetLabel(box, pre_label)
-            model[:] = [getattr(self, 'attr_{}_data'.format(merge_type))]
-            extra_model[:] = [getattr(self, 'attr_{}_extra'.format(merge_type))]
-            cb = gui.comboBox(box, self, 'attr_{}_data'.format(merge_type),
-                              contentsLength=12, callback=self.commit,
-                              model=model)
-            cb.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.Fixed)
-            cb.setFixedWidth(190)
-            gui.widgetLabel(box, between_label)
-            cb = gui.comboBox(box, self, 'attr_{}_extra'.format(merge_type),
-                              contentsLength=12, callback=self.commit,
-                              model=extra_model)
-            cb.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.Fixed)
-            cb.setFixedWidth(190)
-            vbox.layout().addSpacing(6)
-
-        add_option("Append columns from Extra Data",
-                   "by matching", "with", "augment",
-                   self.model, self.extra_model_unique)
-        add_option("Find matching rows", "where",
-                   "equals", "merge",
-                   self.model_unique_with_id, self.extra_model_unique_with_id)
-        add_option("Concatenate tables, merge rows",
-                   "where", "equals", "combine",
-                   self.model_unique_with_id, self.extra_model_unique_with_id)
-        self.set_merging()
+        grp.layout().setSpacing(8)
+
+        self.attr_boxes = ConditionBox(
+            self, self.model, self.extra_model, "", "matches")
+        self.attr_boxes.add_row()
+        box = gui.vBox(self.controlArea, box="Row matching")
+        box.layout().addWidget(self.attr_boxes)
 
         gui.auto_commit(self.controlArea, self, "auto_apply", "&Apply",
                         box=False)
-
-    def set_merging(self):
-        # pylint: disable=invalid-sequence-index
-        # all boxes should be hidden before one is shown, otherwise widget's
-        # layout changes height
-        for box in self.attr_boxes:
-            box.hide()
-        self.attr_boxes[self.merging].show()
+        # connect after wrapping self.commit with gui.auto_commit!
+        self.attr_boxes.vars_changed.connect(self.commit)
+        self.settingsAboutToBePacked.connect(self.store_combo_state)
 
     def change_merging(self):
-        self.set_merging()
         self.commit()
 
-    def _set_unique_model(self, data, model):
-        self.Warning.non_unique_variables.clear()
-        if data is None:
-            model[:] = []
-            return
-        m = [INDEX]
-        non_unique = []
-        for attr in chain(data.domain.variables, data.domain.metas):
-            col = data.get_column_view(attr)[0]
-            if attr.is_primitive():
-                col = col.astype(float)
-                col = col[~np.isnan(col)]
-            else:
-                col = col[~(col == "")]
-            if len(np.unique(col)) == len(col):
-                m.append(attr)
-            else:
-                non_unique.append(attr.name)
-        if non_unique:
-            self.Warning.non_unique_variables(", ".join(non_unique))
-        model[:] = m
-
     @staticmethod
-    def _set_model(data, model):
-        if data is None:
-            model[:] = []
-            return
-        model[:] = list(chain([INDEX], data.domain.variables, data.domain.metas))
-
-    def _add_instanceid_to_models(self):
-        needs_id = self.data is not None and self.extra_data is not None and \
-            len(np.intersect1d(self.data.ids, self.extra_data.ids))
-        for model in (self.model_unique_with_id,
-                      self.extra_model_unique_with_id):
-            has_id = INSTANCEID in model
-            if needs_id and not has_id:
-                model.insert(0, INSTANCEID)
-            elif not needs_id and has_id:
-                model.remove(INSTANCEID)
-
-    def _init_combo_current_items(self, variables, models):
-        for var, model in zip(variables, models):
-            value = getattr(self, var)
-            if model.rowCount():
-                setattr(self, var, value if value in model else INDEX)
+    def _try_set_combo(combo, var):
+        if var in combo.model():
+            combo.setCurrentIndex(combo.model().indexOf(var))
+        else:
+            combo.setCurrentIndex(0)
 
     def _find_best_match(self):
         def get_unique_str_metas_names(model_):
             return [m for m in model_ if isinstance(m, StringVariable)]
 
         def best_match(model, extra_model):
-            attr, extra_attr, n_max_intersect = INDEX, INDEX, 0
+            attr, extra_attr, n_max_intersect = None, None, 0
             str_metas = get_unique_str_metas_names(model)
             extra_str_metas = get_unique_str_metas_names(extra_model)
             for m_a, m_b in product(str_metas, extra_str_metas):
-                n_inter = len(np.intersect1d(self.data[:, m_a].metas,
-                                             self.extra_data[:, m_b].metas))
-                if n_inter > n_max_intersect:
-                    n_max_intersect, attr, extra_attr = n_inter, m_a, m_b
+                col = self.data[:, m_a].metas
+                extra_col = self.extra_data[:, m_b].metas
+                if col.size and extra_col.size \
+                        and isinstance(col[0][0], str) \
+                        and isinstance(extra_col[0][0], str):
+                    n_inter = len(np.intersect1d(col, extra_col))
+                    if n_inter > n_max_intersect:
+                        n_max_intersect, attr, extra_attr = n_inter, m_a, m_b
             return attr, extra_attr
 
-        def set_attrs(attr_name, attr_extra_name, attr, extra_attr):
-            if getattr(self, attr_name) == INDEX and \
-                    getattr(self, attr_extra_name) == INDEX:
-                setattr(self, attr_name, attr)
-                setattr(self, attr_extra_name, extra_attr)
-
         if self.data and self.extra_data:
-            attrs = best_match(self.model, self.extra_model_unique)
-            set_attrs("attr_augment_data", "attr_augment_extra", *attrs)
-            attrs = best_match(self.model_unique_with_id,
-                               self.extra_model_unique_with_id)
-            set_attrs("attr_merge_data", "attr_merge_extra", *attrs)
-            set_attrs("attr_combine_data", "attr_combine_extra", *attrs)
+            box = self.attr_boxes
+            state = box.current_state()
+            if len(state) == 1 \
+                    and not any(isinstance(v, Variable) for v in state[0]):
+                l_var, r_var = best_match(box.model_left, box.model_right)
+                if l_var is not None:
+                    self._try_set_combo(box.rows[0].left_combo, l_var)
+                    self._try_set_combo(box.rows[0].right_combo, r_var)
 
     @Inputs.data
     @check_sql_input
     def setData(self, data):
         self.data = data
-        self._set_model(data, self.model)
-        self._set_unique_model(data, self.model_unique_with_id)
-        self._add_instanceid_to_models()
-        self._init_combo_current_items(
-            ("attr_augment_data", "attr_merge_data", "attr_combine_data"),
-            (self.model, self.model_unique_with_id, self.model_unique_with_id))
+        prev_settings = self.attr_boxes.current_state()
+        self.model.set_domain(data and data.domain)
+        self._restore_combo_current_items(0, prev_settings)
         self.infoBoxData.setText(self.dataInfoText(data))
-        self._find_best_match()
 
     @Inputs.extra_data
     @check_sql_input
     def setExtraData(self, data):
         self.extra_data = data
-        self._set_unique_model(data, self.extra_model_unique)
-        self._set_unique_model(data, self.extra_model_unique_with_id)
-        self._add_instanceid_to_models()
-        self._init_combo_current_items(
-            ("attr_augment_extra", "attr_merge_extra", "attr_combine_extra"),
-            (self.extra_model_unique, self.extra_model_unique_with_id,
-             self.extra_model_unique_with_id))
+        prev_settings = self.attr_boxes.current_state()
+        self.extra_model.set_domain(data and data.domain)
+        self._restore_combo_current_items(1, prev_settings)
         self.infoBoxExtraData.setText(self.dataInfoText(data))
-        self._find_best_match()
+
+    def _restore_combo_current_items(self, side, prev_settings):
+        for row, pair in zip(self.attr_boxes.rows, prev_settings):
+            self._try_set_combo(
+                [row.left_combo, row.right_combo][side], pair[side])
+
+    def store_combo_state(self):
+        self.attr_pairs = (
+            self.data is not None,
+            self.extra_data is not None,
+            [[[INDEX, INSTANCEID].index(x) if isinstance(x, str) else x.name
+              for x in row]
+             for row in self.attr_boxes.current_state()])
 
     def handleNewSignals(self):
+        if self.attr_pairs \
+                and self.attr_pairs[:2] == (self.data is not None,
+                                            self.extra_data is not None):
+            def unpack(x, data):
+                if isinstance(x, int):
+                    return [INDEX, INSTANCEID][x]
+                if data is not None and x in data.domain:
+                    return data.domain[x]
+                else:
+                    return INDEX
+
+            state = [
+                [unpack(row[0], self.data), unpack(row[1], self.extra_data)]
+                for row in self.attr_pairs[2]]
+            self.attr_boxes.set_state(state)
+            # This is schema-only setting, so it should be single-shot
+            # More complicated alternative: make it a context setting
+            self.attr_pairs = None
+        self._find_best_match()
+        box = self.attr_boxes
+
+        state = box.current_state()
+        for i in range(len(box.rows) - 1, 0, -1):
+            if state[i] in state[:i]:
+                box.remove_row(box.rows[i])
+
         self.unconditional_commit()
 
     @staticmethod
@@ -250,6 +380,7 @@ def dataInfoText(data):
                 f"{len(data.domain) + len(data.domain.metas)} variables"
 
     def commit(self):
+        self.Error.clear()
         self.Warning.duplicate_names.clear()
         if not self.data or not self.extra_data:
             merged_data = None
@@ -265,128 +396,179 @@ def commit(self):
 
     def send_report(self):
         # pylint: disable=invalid-sequence-index
-        attr = (self.attr_augment_data, self.attr_merge_data,
-                self.attr_combine_data)
-        extra_attr = (self.attr_augment_extra, self.attr_merge_extra,
-                      self.attr_combine_extra)
-        merging_types = ("Append columns from Extra Data", "Find matching rows",
-                         "Concatenate tables, merge rows")
         self.report_items((
-            ("Merging", merging_types[self.merging]),
-            ("Data attribute", attr[self.merging]),
-            ("Extra data attribute", extra_attr[self.merging])))
+            ("Merging", self.OptionNames[self.merging]),
+            ("Match",
+             ", ".join(
+                 f"{self._get_col_name(left)} with {self._get_col_name(right)}"
+                 for left, right in self.attr_boxes.current_state()))))
 
     def merge(self):
         # pylint: disable=invalid-sequence-index
-        operation = ["augment", "merge", "combine"][self.merging]
-        var_data = getattr(self, "attr_{}_data".format(operation))
-        var_extra_data = getattr(self, "attr_{}_extra".format(operation))
-        merge_method = getattr(self, "_{}_indices".format(operation))
-
-        as_string = not (isinstance(var_data, ContinuousVariable) and
-                         isinstance(var_extra_data, ContinuousVariable))
-        extra_map = self._get_keymap(self.extra_data, var_extra_data, as_string)
-        match_indices = merge_method(var_data, extra_map, as_string)
-        reduced_extra_data = self._compute_reduced_extra_data(var_extra_data)
-        return self._join_table_by_indices(reduced_extra_data, match_indices)
-
-    def _compute_reduced_extra_data(self, var_extra_data):
+        pairs = self.attr_boxes.current_state()
+        if not self._check_pair_types(pairs):
+            return None
+        left_vars, right_vars = zip(*pairs)
+        left_mask = np.full(len(self.data), True)
+        left = np.vstack(tuple(self._values(self.data, var, left_mask)
+                               for var in left_vars)).T
+        right_mask = np.full(len(self.extra_data), True)
+        right = np.vstack(tuple(self._values(self.extra_data, var, right_mask)
+                                for var in right_vars)).T
+        if not self._check_uniqueness(left, left_mask, right, right_mask):
+            return None
+        method = self._merge_methods[self.merging]
+        lefti, righti, rightu = method(self, left, left_mask, right, right_mask)
+        reduced_extra_data = self._compute_reduced_extra_data(right_vars)
+        return self._join_table_by_indices(
+            reduced_extra_data, lefti, righti, rightu)
+
+    def _check_pair_types(self, pairs):
+        for left, right in pairs:
+            if isinstance(left, ContinuousVariable) \
+                    != isinstance(right, ContinuousVariable):
+                self.Error.matching_numeric_with_nonnum(left, right)
+                return False
+            if INDEX in (left, right) and left != right:
+                self.Error.matching_index_with_sth(
+                    self._get_col_name(({left, right} - {INDEX}).pop()))
+                return False
+            if INSTANCEID in (left, right) and left != right:
+                self.Error.matching_id_with_sth(
+                    self._get_col_name(({left, right} - {INSTANCEID}).pop()))
+                return False
+        return True
+
+    @staticmethod
+    def _get_col_name(obj):
+        return f"'{obj.name}'" if isinstance(obj, Variable) else obj.lower()
+
+    def _check_uniqueness(self, left, left_mask, right, right_mask):
+        ok = True
+        masked_right = right[right_mask]
+        if len(set(map(tuple, masked_right))) != len(masked_right):
+            self.Error.nonunique_right()
+            ok = False
+        if self.merging != self.LeftJoin:
+            masked_left = left[left_mask]
+            if len(set(map(tuple, masked_left))) != len(masked_left):
+                self.Error.nonunique_left()
+                ok = False
+        return ok
+
+    def _compute_reduced_extra_data(self, extra_vars):
         """Prepare a table with extra columns that will appear in the merged
         table"""
         domain = self.data.domain
         extra_domain = self.extra_data.domain
         all_vars = set(chain(domain.variables, domain.metas))
-        if self.merging != MergeType.OUTER_JOIN:
-            all_vars.add(var_extra_data)
+
+        if self.merging != self.OuterJoin:
+            all_vars |= set(extra_vars)
         extra_vars = chain(extra_domain.variables, extra_domain.metas)
         return self.extra_data[:, [var for var in extra_vars
                                    if var not in all_vars]]
 
     @staticmethod
-    def _values(data, var, as_string):
+    def _values(data, var, mask):
         """Return an iterotor over keys for rows of the table."""
-        if var == INSTANCEID:
-            return (inst.id for inst in data)
         if var == INDEX:
-            return range(len(data))
+            return np.arange(len(data))
+        if var == INSTANCEID:
+            return np.fromiter(
+                (inst.id for inst in data), count=len(data), dtype=np.int)
         col = data.get_column_view(var)[0]
-        if not as_string:
-            return col
         if var.is_primitive():
-            return (var.str_val(val) if not np.isnan(val) else np.nan
-                    for val in col)
+            col = col.astype(float, copy=False)
+            nans = np.isnan(col)
+            mask *= ~nans
+            if var.is_discrete:
+                col = col.astype(int)
+                col[nans] = len(var.values)
+                col = np.array(var.values + [np.nan])[col]
         else:
-            return (str(val) if val else np.nan for val in col)
-
-    @classmethod
-    def _get_keymap(cls, data, var, as_string):
-        """Return a generator of pairs (key, index) by enumerating and
-        switching the values for rows (method `_values`).
-        """
-        return ((val, i)
-                for i, val in enumerate(cls._values(data, var, as_string)))
+            col = col.copy()
+            defined = col.astype(bool)
+            mask *= defined
+            col[~mask] = np.nan
+        return col
 
-    def _augment_indices(self, var_data, extra_map, as_string):
+    def _left_join_indices(self, left, left_mask, right, right_mask):
         """Compute a two-row array of indices:
         - the first row contains indices for the primary table,
         - the second row contains the matching rows in the extra table or -1"""
         data = self.data
-        extra_map = dict(extra_map)
-        # Don't match nans. This is needed since numpy supports using nan as
-        # keys. If numpy fixes this, the below conditions will always be false,
-        # so we're OK again.
-        if np.nan in extra_map:
-            del extra_map[np.nan]
-        keys = (extra_map.get(val, -1)
-                for val in self._values(data, var_data, as_string))
-        return np.vstack((np.arange(len(data), dtype=np.int64),
-                          np.fromiter(keys, dtype=np.int64, count=len(data))))
-
-    def _merge_indices(self, var_data, extra_map, as_string):
+        # Don't match nans. This is needed since numpy may change nan to string
+        # nan, so nan's will match each other
+        indices = np.arange(len(right))
+        indices[~right_mask] = -1
+        if right.shape[1] == 1:
+            # The more common case can be handled faster
+            right_map = dict(zip(right.flatten(), indices))
+            righti = (right_map.get(val, -1) for val in left.flatten())
+        else:
+            right_map = dict(zip(map(tuple, right), indices))
+            righti = (right_map.get(tuple(val), -1) for val in left)
+        righti = np.fromiter(righti, dtype=np.int64, count=len(data))
+        lefti = np.arange(len(data), dtype=np.int64)
+        righti[lefti[~left_mask]] = -1
+        return lefti, righti, None
+
+    def _inner_join_indices(self, left, left_mask, right, right_mask):
         """Use _augment_indices to compute the array of indices,
         then remove those with no match in the second table"""
-        augmented = self._augment_indices(var_data, extra_map, as_string)
-        return augmented[:, augmented[1] != -1]
+        lefti, righti, _ = \
+            self._left_join_indices(left, left_mask, right, right_mask)
+        mask = righti != [-1]
+        return lefti[mask], righti[mask], None
 
-    def _combine_indices(self, var_data, extra_map, as_string):
+    def _outer_join_indices(self, left, left_mask, right, right_mask):
         """Use _augment_indices to compute the array of indices,
         then add rows in the second table without a match in the first"""
-        to_add, extra_map = tee(extra_map)
-        # dict instead of set because we have pairs; we'll need only keys
-        key_map = dict(self._get_keymap(self.data, var_data, as_string))
-        # _augment indices will skip rows where the key in the left table
-        # is nan. See comment in `_augment_indices` wrt numpy and nan in dicts
-        if np.nan in key_map:
-            del key_map[np.nan]
-        keys = np.fromiter((j for key, j in to_add if key not in key_map),
-                           dtype=np.int64)
-        right_indices = np.vstack((np.full(len(keys), -1, np.int64), keys))
-        return np.hstack(
-            (self._augment_indices(var_data, extra_map, as_string),
-             right_indices))
-
-    def _join_table_by_indices(self, reduced_extra, indices):
+        lefti, righti, _ = \
+            self._left_join_indices(left, left_mask, right, right_mask)
+        unused = np.full(len(right), True)
+        unused[righti] = False
+        if len(right) - 1 not in righti:
+            # righti can include -1, which sets the last element as used
+            unused[-1] = True
+        return lefti, righti, np.nonzero(unused)[0]
+
+    _merge_methods = [
+        _left_join_indices, _inner_join_indices, _outer_join_indices]
+
+    def _join_table_by_indices(self, reduced_extra, lefti, righti, rightu):
         """Join (horizontally) self.data and reduced_extra, taking the pairs
         of rows given in indices"""
-        if not indices.size:
+        if not lefti.size:
             return None
         domain = Orange.data.Domain(
             *(getattr(self.data.domain, x) + getattr(reduced_extra.domain, x)
               for x in ("attributes", "class_vars", "metas")))
-        X = self._join_array_by_indices(self.data.X, reduced_extra.X, indices)
+        X = self._join_array_by_indices(self.data.X, reduced_extra.X, lefti, righti)
         Y = self._join_array_by_indices(
-            np.c_[self.data.Y], np.c_[reduced_extra.Y], indices)
+            np.c_[self.data.Y], np.c_[reduced_extra.Y], lefti, righti)
         string_cols = [i for i, var in enumerate(domain.metas) if var.is_string]
         metas = self._join_array_by_indices(
-            self.data.metas, reduced_extra.metas, indices, string_cols)
+            self.data.metas, reduced_extra.metas, lefti, righti, string_cols)
+        if rightu is not None:
+            extras = self.extra_data[rightu].transform(domain)
+            X = np.vstack((X, extras.X))
+            Y = np.vstack((Y, extras.Y))
+            metas = np.vstack((metas, extras.metas))
         table = Orange.data.Table.from_numpy(domain, X, Y, metas)
         table.name = getattr(self.data, 'name', '')
         table.attributes = getattr(self.data, 'attributes', {})
-        table.ids = self.data.ids
+        if rightu is not None:
+            table.ids = np.hstack(
+                (self.data.ids, self.extra_data.ids[rightu]))
+        else:
+            table.ids = self.data.ids[lefti]
+
         return table
 
     @staticmethod
-    def _join_array_by_indices(left, right, indices, string_cols=None):
+    def _join_array_by_indices(left, right, lefti, righti, string_cols=None):
         """Join (horizontally) two arrays, taking pairs of rows given in indices
         """
         def prepare(arr, inds, str_cols):
@@ -406,10 +588,30 @@ def prepare(arr, inds, str_cols):
         left_width = left.shape[1]
         str_left = [i for i in string_cols or () if i < left_width]
         str_right = [i - left_width for i in string_cols or () if i >= left_width]
-        res = hstack((prepare(left, indices[0], str_left),
-                      prepare(right, indices[1], str_right)))
+        res = hstack((prepare(left, lefti, str_left),
+                      prepare(right, righti, str_right)))
         return res
 
+    @staticmethod
+    def migrate_settings(settings, version=None):
+        def mig_value(x):
+            if x == "Position (index)":
+                return INDEX
+            if x == "Source position (index)":
+                return INSTANCEID
+            return x
+
+        if not version:
+            operations = ("augment", "merge", "combine")
+            oper = operations[settings["merging"]]
+            settings["attr_pairs"] = (
+                True, True,
+                [(mig_value(settings[f"attr_{oper}_data"]),
+                  mig_value(settings[f"attr_{oper}_extra"]))])
+            for oper in operations:
+                del settings[f"attr_{oper}_data"]
+                del settings[f"attr_{oper}_extra"]
+
 
 if __name__ == "__main__":  # pragma: no cover
     WidgetPreview(OWMergeData).run(


Test patch:
diff --git a/Orange/widgets/data/tests/test_owmergedata.py b/Orange/widgets/data/tests/test_owmergedata.py
--- a/Orange/widgets/data/tests/test_owmergedata.py
+++ b/Orange/widgets/data/tests/test_owmergedata.py
@@ -1,12 +1,15 @@
 # Test methods with long descriptive names can omit docstrings
 # pylint: disable=missing-docstring
 from itertools import chain
-from unittest.mock import patch
+import unittest
 
 import numpy as np
 import scipy.sparse as sp
 
-from Orange.data import Table, Domain, DiscreteVariable, StringVariable
+from AnyQt.QtCore import Qt
+
+from Orange.data import Table, Domain, DiscreteVariable, StringVariable, \
+    ContinuousVariable
 from Orange.widgets.data.owmergedata import OWMergeData, INSTANCEID, INDEX
 from Orange.widgets.tests.base import WidgetTest
 from Orange.tests import test_filename
@@ -50,86 +53,384 @@ def test_input_remove(self):
         self.send_signal(self.widget.Inputs.data, None)
         self.send_signal(self.widget.Inputs.extra_data, None)
 
-    def test_combobox_items_left(self):
-        """Check if combo box content is properly set for merging option
-        'Append columns from Extra Data'"""
+    def test_combobox_items(self):
+        """Check if combo box content is properly set"""
         domainA, domainB = self.dataA.domain, self.dataB.domain
-        data_combo = self.widget.controls.attr_augment_data
-        extra_combo = self.widget.controls.attr_augment_extra
+        row = self.widget.attr_boxes.rows[0]
+        data_combo, extra_combo = row.left_combo, row.right_combo
 
         self.send_signal(self.widget.Inputs.data, self.dataA)
         self.send_signal(self.widget.Inputs.extra_data, self.dataA)
-        data_items = list(chain([INDEX], domainA.variables, domainA.metas))
-        extra_items = list(chain(
-            [INDEX], domainA.variables[::2], domainA.metas[1:]))
+        data_items = extra_items = list(
+            chain([INDEX, INSTANCEID], domainA.variables, domainA.metas))
         self.assertListEqual(data_combo.model()[:], data_items)
         self.assertListEqual(extra_combo.model()[:], extra_items)
 
         self.send_signal(self.widget.Inputs.extra_data, self.dataB)
-        extra_items = list(chain([INDEX], domainB.variables, domainB.metas))
+        data_items = list(
+            chain([INDEX, INSTANCEID], domainA.variables, domainA.metas))
+        extra_items = list(
+            chain([INDEX, INSTANCEID], domainB.variables, domainB.metas))
         self.assertListEqual(data_combo.model()[:], data_items)
         self.assertListEqual(extra_combo.model()[:], extra_items)
 
         self.send_signal(self.widget.Inputs.data, self.dataB)
-        data_items = list(chain([INDEX], domainB.variables, domainB.metas))
+        data_items = extra_items = list(
+            chain([INDEX, INSTANCEID], domainB.variables, domainB.metas))
         self.assertListEqual(data_combo.model()[:], data_items)
         self.assertListEqual(extra_combo.model()[:], extra_items)
 
-    def test_combobox_items_inner(self):
-        """Check if combo box content is properly set for merging option
-        'Find matching rows'"""
-        domainA, domainB = self.dataA.domain, self.dataB.domain
-        data_combo = self.widget.controls.attr_merge_data
-        extra_combo = self.widget.controls.attr_merge_extra
+    def test_combo_box_sync(self):
+        row = self.widget.attr_boxes.rows[0]
+        data_combo, extra_combo = row.left_combo, row.right_combo
 
         self.send_signal(self.widget.Inputs.data, self.dataA)
         self.send_signal(self.widget.Inputs.extra_data, self.dataA)
-        self.widget.controls.merging.buttons[1].click()
-        data_items = extra_items = list(chain(
-            [INSTANCEID, INDEX], domainA.variables[::2], domainA.metas[1:]))
-        self.assertListEqual(data_combo.model()[:], data_items)
-        self.assertListEqual(extra_combo.model()[:], extra_items)
 
+        extra_combo.setCurrentIndex(3)
+        data_combo.setCurrentIndex(0)
+        data_combo.activated.emit(0)
+        self.assertEqual(data_combo.currentIndex(), 0)
+        self.assertEqual(extra_combo.currentIndex(), 0)
+
+        data_combo.setCurrentIndex(1)
+        data_combo.activated.emit(1)
+        self.assertEqual(data_combo.currentIndex(), 1)
+        self.assertEqual(extra_combo.currentIndex(), 1)
+
+        data_combo.setCurrentIndex(2)
+        data_combo.activated.emit(2)
+
+        extra_combo.setCurrentIndex(0)
+        extra_combo.activated.emit(0)
+        self.assertEqual(data_combo.currentIndex(), 0)
+        self.assertEqual(extra_combo.currentIndex(), 0)
+
+        extra_combo.setCurrentIndex(1)
+        extra_combo.activated.emit(1)
+        self.assertEqual(data_combo.currentIndex(), 1)
+        self.assertEqual(extra_combo.currentIndex(), 1)
+
+    def test_attr_combo_tooltips(self):
+        row = self.widget.attr_boxes.rows[0]
+        model = row.left_combo.model()
+        self.send_signal(self.widget.Inputs.data, self.dataA)
+        self.send_signal(self.widget.Inputs.extra_data, self.dataA)
+
+        tip = model.data(model.index(2, 0), Qt.ToolTipRole)
+        # Test the test; if general tooltips ever change and the following
+        # assert fails, the rest of this test has to be modified accordingly
+        self.assertTrue(tip.startswith("<b>"))
+
+        # Just test that tooltip is a string (implicitly) and that it's not
+        # a generic DomainModel tooltip
+        tip = model.data(model.index(0, 0), Qt.ToolTipRole)
+        self.assertFalse(tip.startswith("<b>"))
+
+        tip = model.data(model.index(1, 0), Qt.ToolTipRole)
+        self.assertFalse(tip.startswith("<b>"))
+
+    def test_match_attr_name(self):
+        widget = self.widget
+        row = widget.attr_boxes.rows[0]
+        data_combo, extra_combo = row.left_combo, row.right_combo
+
+        domainA = Domain([DiscreteVariable("dA1", ("a", "b", "c", "d")),
+                          DiscreteVariable("dA2", ("aa", "bb")),
+                          DiscreteVariable("dA3", ("aa", "bb"))],
+                         DiscreteVariable("cls", ("aaa", "bbb", "ccc")),
+                         [DiscreteVariable("mA1", ("cc", "dd")),
+                          StringVariable("mA2")])
+        XA = np.array([[0, 0, 0], [1, 1, 0], [2, 0, 0], [3, 1, 0]])
+        yA = np.array([0, 1, 2, np.nan])
+        metasA = np.array([[0.0, "m1"], [1.0, "m2"], [np.nan, "m3"],
+                           [0.0, "m4"]]).astype(object)
+
+        domainB = Domain([DiscreteVariable("dB1", values=("a", "b", "c")),
+                          ContinuousVariable("dA2")],
+                         None,
+                         [StringVariable("cls"),
+                          DiscreteVariable("dA1", ("m4", "m5"))])
+        XB = np.array([[0, 0], [1, 1], [2, np.nan]])
+        yB = np.empty((3, 0))
+        metasB = np.array([[np.nan, np.nan], [1, 1], [0, 0]]).astype(object)
+        dataA = Table(domainA, XA, yA, metasA)
+        dataA.name = 'dataA'
+        dataA.attributes = 'dataA attributes'
+        dataB = Table(domainB, XB, yB, metasB)
+        dataB.name = 'dataB'
+        dataB.attributes = 'dataB attributes'
+
+        self.send_signal(widget.Inputs.data, dataA)
+        self.send_signal(widget.Inputs.extra_data, dataB)
+
+        # match variable if available and the other combo is Row Index
+        extra_combo.setCurrentIndex(0)
+        extra_combo.activated.emit(0)
+        data_combo.setCurrentIndex(2)
+        data_combo.activated.emit(2)
+        self.assertEqual(extra_combo.currentIndex(), 5)
+
+        # match variable if available and the other combo is ID
+        extra_combo.setCurrentIndex(1)
+        extra_combo.activated.emit(1)
+        data_combo.setCurrentIndex(2)
+        data_combo.activated.emit(2)
+        self.assertEqual(extra_combo.currentIndex(), 5)
+
+        # don't match variable if other combo is set
+        extra_combo.setCurrentIndex(4)
+        extra_combo.activated.emit(4)
+        data_combo.setCurrentIndex(2)
+        data_combo.activated.emit(2)
+        self.assertEqual(extra_combo.currentIndex(), 4)
+
+        # don't match if nothing to match to
+        extra_combo.setCurrentIndex(0)
+        extra_combo.activated.emit(0)
+        data_combo.setCurrentIndex(4)
+        data_combo.activated.emit(4)
+        self.assertEqual(extra_combo.currentIndex(), 0)
+
+        # don't match numeric with non-numeric
+        extra_combo.setCurrentIndex(0)
+        extra_combo.activated.emit(0)
+        data_combo.setCurrentIndex(3)
+        data_combo.activated.emit(3)
+        self.assertEqual(extra_combo.currentIndex(), 0)
+
+        # allow matching string with discrete
+        extra_combo.setCurrentIndex(0)
+        extra_combo.activated.emit(0)
+        data_combo.setCurrentIndex(5)
+        data_combo.activated.emit(5)
+        self.assertEqual(extra_combo.currentIndex(), 4)
+
+    def test_add_row_button(self):
+        boxes = self.widget.attr_boxes
+        boxes.set_state([(INSTANCEID, INSTANCEID), (INSTANCEID, INSTANCEID)])
+        boxes.rows[-1].add_button.clicked.emit()
+        self.assertEqual(len(boxes.rows), 3)
+        self.assertEqual(boxes.layout().count(), 3)
+
+    def test_remove_row(self):
+        widget = self.widget
+        boxes = widget.attr_boxes
+        var0, var1 = self.dataA.domain.attributes[:2]
+
+        self.send_signal(self.widget.Inputs.data, self.dataA)
+        self.send_signal(self.widget.Inputs.extra_data, self.dataA)
+
+        boxes.set_state(
+            [(INDEX, INDEX), (INSTANCEID, INSTANCEID), (var0, var1)])
+        for i, row in enumerate(boxes.rows):
+            self.assertTrue(row.remove_button.isEnabled())
+            self.assertEqual(row.remove_button.text(), "×")
+            self.assertEqual(row.add_button.isEnabled(), i == 2)
+            self.assertEqual(row.add_button.text(), ["", "+"][i == 2])
+
+        boxes.rows[1].remove_button.clicked.emit()
+        self.assertEqual(boxes.current_state(), [(INDEX, INDEX), (var0, var1)])
+        for i, row in enumerate(boxes.rows):
+            self.assertTrue(row.remove_button.isEnabled())
+            self.assertEqual(row.remove_button.text(), "×")
+            self.assertEqual(row.add_button.isEnabled(), i == 1)
+            self.assertEqual(row.add_button.text(), ["", "+"][i])
+
+        boxes.rows[1].remove_button.clicked.emit()
+        self.assertEqual(boxes.current_state(), [(INDEX, INDEX)])
+        row = boxes.rows[0]
+        self.assertFalse(row.remove_button.isEnabled())
+        self.assertEqual(row.remove_button.text(), "")
+        self.assertTrue(row.add_button.isEnabled())
+        self.assertEqual(row.add_button.text(), "+")
+
+        boxes.set_state(
+            [(INDEX, INDEX), (INSTANCEID, INSTANCEID), (var0, var1)])
+        boxes.rows[2].remove_button.clicked.emit()
+        self.assertEqual(
+            boxes.current_state(), [(INDEX, INDEX), (INSTANCEID, INSTANCEID)])
+        for i, row in enumerate(boxes.rows):
+            self.assertTrue(row.remove_button.isEnabled())
+            self.assertEqual(row.remove_button.text(), "×")
+            self.assertEqual(row.add_button.isEnabled(), i == 1)
+            self.assertEqual(row.add_button.text(), ["", "+"][i == 1])
+
+    def test_dont_remove_single_row(self):
+        widget = self.widget
+        rows = widget.attr_boxes.rows
+        self.assertEqual(len(rows), 1)
+        rows[0].remove_button.clicked.emit()
+        self.assertEqual(len(rows), 1)
+
+    def test_retrieve_settings(self):
+        widget = self.widget
+        boxes = widget.attr_boxes
+        var0, var1 = self.dataA.domain.attributes[:2]
+
+        self.send_signal(self.widget.Inputs.data, self.dataA)
+        self.send_signal(self.widget.Inputs.extra_data, self.dataA)
+
+        boxes.set_state(
+            [(INDEX, INDEX), (INSTANCEID, INSTANCEID), (var0, var1)])
+
+        settings = widget.settingsHandler.pack_data(widget)
+
+        widget2 = self.create_widget(OWMergeData, stored_settings=settings)
+        widget2.attr_boxes.set_state([(INDEX, INDEX)])
+        self.send_signals(
+            [(widget2.Inputs.data, self.dataA),
+             (widget2.Inputs.extra_data, self.dataA)],
+            widget=widget2)
+        self.assertEqual(
+            widget2.attr_boxes.current_state(),
+            [(INDEX, INDEX), (INSTANCEID, INSTANCEID), (var0, var1)])
+
+    def test_retrieve_settings_with_missing_var(self):
+        widget = self.widget
+        boxes = widget.attr_boxes
+        var0, var1 = self.dataA.domain.attributes[:2]
+
+        self.send_signal(self.widget.Inputs.data, self.dataA)
+        self.send_signal(self.widget.Inputs.extra_data, self.dataA)
+
+        boxes.set_state(
+            [(INDEX, INDEX), (INSTANCEID, INSTANCEID), (var0, var1)])
+
+        domain = self.dataA.domain
+
+        # The left combo in the last row must change to INDEX due to missing var
+        data2 = self.dataA.transform(
+            Domain(domain.attributes[1:], domain.class_var, domain.metas))
+        settings = widget.settingsHandler.pack_data(widget)
+        widget2 = self.create_widget(OWMergeData, stored_settings=settings)
+        widget2.attr_boxes.set_state([(INDEX, INDEX)])
+        self.send_signals(
+            [(widget2.Inputs.data, data2),
+             (widget2.Inputs.extra_data, data2)],
+            widget=widget2)
+        self.assertEqual(
+            widget2.attr_boxes.current_state(),
+            [(INDEX, INDEX), (INSTANCEID, INSTANCEID), (INDEX, var1)])
+
+        # The last row is changed to INDEX, INDEX and removed as duplicate
+        data2 = self.dataA.transform(
+            Domain(domain.attributes[2:], domain.class_var, domain.metas))
+        settings = widget.settingsHandler.pack_data(widget)
+        widget2 = self.create_widget(OWMergeData, stored_settings=settings)
+        widget2.attr_boxes.set_state([(INDEX, INDEX)])
+        self.send_signals(
+            [(widget2.Inputs.data, data2),
+             (widget2.Inputs.extra_data, data2)],
+            widget=widget2)
+        self.assertEqual(
+            widget2.attr_boxes.current_state(),
+            [(INDEX, INDEX), (INSTANCEID, INSTANCEID)])
+
+    def test_migrate_settings(self):
+        attr1, attr2, attr3, attr4, attr5 = [object() for _ in range(5)]
+        orig_settings = dict(
+            attr_augment_data=attr1, attr_augment_extra=attr2,
+            attr_merge_data=attr3, attr_merge_extra=attr4,
+            attr_combine_data=attr5, attr_combine_extra='Position (index)')
+
+        widget = self.create_widget(
+            OWMergeData, stored_settings=dict(merging=0, **orig_settings))
+        self.assertEqual(widget.attr_pairs, (True, True, [(attr1, attr2)]))
+
+        widget = self.create_widget(
+            OWMergeData, stored_settings=dict(merging=1, **orig_settings))
+        self.assertEqual(widget.attr_pairs, (True, True, [(attr3, attr4)]))
+
+        widget = self.create_widget(
+            OWMergeData, stored_settings=dict(merging=2, **orig_settings))
+        self.assertEqual(widget.attr_pairs, (True, True, [(attr5, INDEX)]))
+
+        orig_settings["attr_combine_extra"] = "Source position (index)"
+        widget = self.create_widget(
+            OWMergeData, stored_settings=dict(merging=2, **orig_settings))
+        self.assertEqual(widget.attr_pairs, (True, True, [(attr5, INSTANCEID)]))
+
+    @unittest.skip("widget doesn't work this way, but could in the future")
+    def test_switch_domain(self):  # pragma: no cover
+        widget = self.widget
+        boxes = widget.attr_boxes
+        var0, var1 = self.dataA.domain.attributes[:2]
+
+        self.send_signal(self.widget.Inputs.data, self.dataA)
+        self.send_signal(self.widget.Inputs.extra_data, self.dataA)
+        boxes.set_state(
+            [(INDEX, INDEX), (INSTANCEID, INSTANCEID), (var0, var1)])
+
+        self.send_signal(self.widget.Inputs.data, self.dataB)
         self.send_signal(self.widget.Inputs.extra_data, self.dataB)
-        data_items = list(chain(
-            [INDEX], domainA.variables[::2], domainA.metas[1:]))
-        extra_items = list(chain([INDEX], domainB.variables, domainB.metas))
-        self.assertListEqual(data_combo.model()[:], data_items)
-        self.assertListEqual(extra_combo.model()[:], extra_items)
+        self.assertEqual(
+            boxes.current_state(),
+            [(INDEX, INDEX), (INSTANCEID, INSTANCEID)])
+
+        self.send_signal(self.widget.Inputs.data, self.dataA)
+        self.send_signal(self.widget.Inputs.extra_data, self.dataA)
+        boxes.set_state([(var0, var1), (var1, var0)])
 
         self.send_signal(self.widget.Inputs.data, self.dataB)
-        data_items = extra_items = list(chain(
-            [INSTANCEID, INDEX], domainB.variables, domainB.metas))
-        self.assertListEqual(data_combo.model()[:], data_items)
-        self.assertListEqual(extra_combo.model()[:], extra_items)
+        self.send_signal(self.widget.Inputs.extra_data, self.dataB)
+        self.assertEqual(boxes.current_state(), [(INDEX, INDEX)])
 
-    def test_combobox_items_outer(self):
-        """Check if combo box content is properly set for merging option
-        'Concatenate tables, merge rows'"""
-        domainA, domainB = self.dataA.domain, self.dataB.domain
-        data_combo = self.widget.controls.attr_combine_data
-        extra_combo = self.widget.controls.attr_combine_extra
+        self.send_signal(self.widget.Inputs.data, self.dataA)
+        self.send_signal(self.widget.Inputs.extra_data, self.dataA)
+        boxes.set_state([(var1, var1), (var0, var0)])
+
+        domain2 = Domain(self.dataA.domain.attributes[:1],
+                         self.dataA.domain.class_var)
+        data2 = self.dataA.transform(domain2)
+        self.send_signal(self.widget.Inputs.data, data2)
+        self.send_signal(self.widget.Inputs.extra_data, data2)
+        self.assertEqual(boxes.current_state(), [(var0, var0)])
+
+    def test_domain_switch_cleanup(self):
+        widget = self.widget
+        boxes = widget.attr_boxes
+        var0, var1 = self.dataA.domain.attributes[:2]
 
         self.send_signal(self.widget.Inputs.data, self.dataA)
         self.send_signal(self.widget.Inputs.extra_data, self.dataA)
-        self.widget.controls.merging.buttons[1].click()
-        data_items = extra_items = list(chain(
-            [INSTANCEID, INDEX], domainA.variables[::2], domainA.metas[1:]))
-        self.assertListEqual(data_combo.model()[:], data_items)
-        self.assertListEqual(extra_combo.model()[:], extra_items)
+        boxes.set_state(
+            [(INDEX, INDEX), (INSTANCEID, INSTANCEID), (var0, var1)])
 
+        self.send_signal(self.widget.Inputs.data, self.dataB)
         self.send_signal(self.widget.Inputs.extra_data, self.dataB)
-        data_items = list(chain(
-            [INDEX], domainA.variables[::2], domainA.metas[1:]))
-        extra_items = list(chain([INDEX], domainB.variables, domainB.metas))
-        self.assertListEqual(data_combo.model()[:], data_items)
-        self.assertListEqual(extra_combo.model()[:], extra_items)
+        self.assertEqual(
+            boxes.current_state(),
+            [(INDEX, INDEX), (INSTANCEID, INSTANCEID)])
 
-        self.send_signal(self.widget.Inputs.data, self.dataB)
-        data_items = extra_items = list(chain(
-            [INSTANCEID, INDEX], domainB.variables, domainB.metas))
-        self.assertListEqual(data_combo.model()[:], data_items)
-        self.assertListEqual(extra_combo.model()[:], extra_items)
+    def test_report(self):
+        widget = self.widget
+        boxes = widget.attr_boxes
+        var0, var1 = self.dataA.domain.attributes[:2]
+
+        self.send_signal(self.widget.Inputs.data, self.dataA)
+        self.send_signal(self.widget.Inputs.extra_data, self.dataA)
+        boxes.set_state(
+            [(INDEX, INDEX), (INSTANCEID, INSTANCEID), (var0, var1)])
+        widget.send_report()
+        # Don't crash, that's it
+
+    def test_no_matches(self):
+        """Check output is None when there are no matches in inner join"""
+        self.send_signal(self.widget.Inputs.data, self.dataA)
+        self.send_signal(self.widget.Inputs.extra_data, self.dataB)
+        domA = self.dataA.domain
+        domB = self.dataB.domain
+
+        self.widget.attr_boxes.set_state([(domA["dA1"], domB["dB2"])])
+        self.widget.controls.merging.buttons[self.widget.LeftJoin].click()
+        self.assertIsNotNone(self.get_output(self.widget.Outputs.data))
+
+        self.widget.controls.merging.buttons[self.widget.InnerJoin].click()
+        self.assertIsNone(self.get_output(self.widget.Outputs.data))
+
+        self.widget.controls.merging.buttons[self.widget.OuterJoin].click()
+        self.assertIsNotNone(self.get_output(self.widget.Outputs.data))
 
     def test_output_merge_by_ids_inner(self):
         """Check output for merging option 'Find matching rows' by
@@ -139,9 +440,8 @@ def test_output_merge_by_ids_inner(self):
                        np.array([[1.0, "m2"], [np.nan, "m3"]]).astype(object))
         self.send_signal(self.widget.Inputs.data, self.dataA[:3, [0, "cls", -1]])
         self.send_signal(self.widget.Inputs.extra_data, self.dataA[1:, [1, "cls", -2]])
-        self.widget.attr_merge_data = "Source position (index)"
-        self.widget.attr_merge_extra = "Source position (index)"
-        self.widget.controls.merging.buttons[1].click()
+        self.widget.attr_boxes.set_state([(INSTANCEID, INSTANCEID)])
+        self.widget.controls.merging.buttons[self.widget.InnerJoin].click()
         self.assertTablesEqual(self.get_output(self.widget.Outputs.data), result)
 
     def test_output_merge_by_ids_outer(self):
@@ -149,16 +449,19 @@ def test_output_merge_by_ids_outer(self):
         Source position (index)"""
         domain = self.dataA.domain
         result = Table(domain,
-                       np.array([[0, np.nan], [1, 1], [2, 0], [np.nan, 1]]),
-                       np.array([0, 1, 2, np.nan]),
-                       np.array([[0.0, ""], [1.0, "m2"], [np.nan, "m3"],
-                                 [np.nan, "m4"]]).astype(object))
-        self.send_signal(self.widget.Inputs.data, self.dataA[:3, [0, "cls", -1]])
-        self.send_signal(self.widget.Inputs.extra_data, self.dataA[1:, [1, "cls", -2]])
-        self.widget.attr_combine_data = "Source position (index)"
-        self.widget.attr_combine_extra = "Source position (index)"
-        self.widget.controls.merging.buttons[2].click()
-        self.assertTablesEqual(self.get_output(self.widget.Outputs.data), result)
+                       np.array([[1, 1], [2, 0], [3, np.nan], [np.nan, 0]]),
+                       np.array([1, 2, np.nan, 0]),
+                       np.array([[1.0, "m2"], [np.nan, "m3"],
+                                 [0.0, ""], [np.nan, "m1"]]).astype(object))
+        self.widget.attr_boxes.set_state([(INSTANCEID, INSTANCEID)])
+        self.widget.merging = 2
+        self.widget.controls.merging.buttons[self.widget.OuterJoin].click()
+        self.send_signal(self.widget.Inputs.data, self.dataA[1:, [0, "cls", -1]])
+        self.send_signal(self.widget.Inputs.extra_data, self.dataA[:3, [1, "cls", -2]])
+        out = self.get_output(self.widget.Outputs.data)
+        self.assertTablesEqual(out, result)
+        np.testing.assert_equal(
+            out.ids, np.hstack((self.dataA.ids[1:], self.dataA.ids[:1])))
 
     def test_output_merge_by_index_left(self):
         """Check output for merging option 'Append columns from Extra Data' by
@@ -234,8 +537,7 @@ def test_output_merge_by_attribute_left(self):
 
         self.send_signal(self.widget.Inputs.data, self.dataA)
         self.send_signal(self.widget.Inputs.extra_data, self.dataB)
-        self.widget.attr_augment_data = domainA[0]
-        self.widget.attr_augment_extra = domainB[0]
+        self.widget.attr_boxes.set_state([(domainA[0], domainB[0])])
         self.widget.commit()
         output = self.get_output(self.widget.Outputs.data)
         self.assertTablesEqual(output, result)
@@ -257,9 +559,8 @@ def test_output_merge_by_attribute_inner(self):
 
         self.send_signal(self.widget.Inputs.data, self.dataA)
         self.send_signal(self.widget.Inputs.extra_data, self.dataB)
-        self.widget.attr_merge_data = domainA[0]
-        self.widget.attr_merge_extra = domainB[0]
-        self.widget.controls.merging.buttons[1].click()
+        self.widget.attr_boxes.set_state([(domainA[0], domainB[0])])
+        self.widget.controls.merging.buttons[self.widget.InnerJoin].click()
         self.assertTablesEqual(self.get_output(self.widget.Outputs.data), result)
 
     def test_output_merge_by_attribute_outer(self):
@@ -279,11 +580,33 @@ def test_output_merge_by_attribute_outer(self):
 
         self.send_signal(self.widget.Inputs.data, self.dataA)
         self.send_signal(self.widget.Inputs.extra_data, self.dataB)
-        self.widget.attr_combine_data = domainA[0]
-        self.widget.attr_combine_extra = domainB[0]
-        self.widget.controls.merging.buttons[2].click()
+        self.widget.attr_boxes.set_state([(domainA[0], domainB[0])])
+        self.widget.controls.merging.buttons[self.widget.OuterJoin].click()
         self.assertTablesEqual(self.get_output(self.widget.Outputs.data), result)
 
+    def test_output_merge_by_attribute_outer_same_attr(self):
+        """Values of columns from extra aata are copied to left part if they
+        match"""
+        name = StringVariable("name")
+        domainA = Domain([ContinuousVariable("x")], None, [name])
+        domainB = Domain([ContinuousVariable("y")], None, [name])
+        xA = np.array([[0], [1], [2]])
+        mA = np.array([["a"], ["b"], ["c"]])
+        xB = np.array([[4], [5], [6], [7]])
+        mB = np.array([["b"], ["d"], ["a"], ["c"]])
+        dataA = Table(domainA, xA, None, mA)
+        dataB = Table(domainB, xB, None, mB)
+
+        self.send_signal(self.widget.Inputs.data, dataA)
+        self.send_signal(self.widget.Inputs.extra_data, dataB)
+        self.widget.attr_boxes.set_state([(name, name)])
+        self.widget.controls.merging.buttons[self.widget.OuterJoin].click()
+        out = self.get_output(self.widget.Outputs.data)
+        np.testing.assert_equal(
+            out.X,
+            np.array([[0, 6], [1, 4], [2, 7], [np.nan, 5]]))
+        self.assertEqual(" ".join(out.metas.flatten()), "a b c d")
+
     def test_output_merge_by_class_left(self):
         """Check output for merging option 'Append columns from Extra Data' by
         class variable"""
@@ -300,8 +623,7 @@ class variable"""
 
         self.send_signal(self.widget.Inputs.data, self.dataA)
         self.send_signal(self.widget.Inputs.extra_data, self.dataB)
-        self.widget.attr_augment_data = domainA[2]
-        self.widget.attr_augment_extra = domainB[2]
+        self.widget.attr_boxes.set_state([(domainA[2], domainB[2])])
         self.widget.commit()
         self.assertTablesEqual(self.get_output(self.widget.Outputs.data), result)
 
@@ -319,8 +641,8 @@ def test_output_merge_by_class_inner(self):
 
         self.send_signal(self.widget.Inputs.data, self.dataA)
         self.send_signal(self.widget.Inputs.extra_data, self.dataB)
-        self.widget.attr_merge_data = domainA.class_vars[0]
-        self.widget.attr_merge_extra = domainB.class_vars[0]
+        self.widget.attr_boxes.set_state(
+            [(domainA.class_vars[0], domainB.class_vars[0])])
         self.widget.controls.merging.buttons[1].click()
         self.assertTablesEqual(self.get_output(self.widget.Outputs.data), result)
 
@@ -343,8 +665,8 @@ class variable"""
 
         self.send_signal(self.widget.Inputs.data, self.dataA)
         self.send_signal(self.widget.Inputs.extra_data, self.dataB)
-        self.widget.attr_combine_data = domainA.class_vars[0]
-        self.widget.attr_combine_extra = domainB.class_vars[0]
+        self.widget.attr_boxes.set_state(
+            [(domainA.class_vars[0], domainB.class_vars[0])])
         self.widget.controls.merging.buttons[2].click()
         self.assertTablesEqual(self.get_output(self.widget.Outputs.data), result)
 
@@ -365,8 +687,7 @@ def test_output_merge_by_meta_left(self):
 
         self.send_signal(self.widget.Inputs.data, self.dataA)
         self.send_signal(self.widget.Inputs.extra_data, self.dataB)
-        self.widget.attr_augment_data = domainA[-2]
-        self.widget.attr_augment_extra = domainB[-1]
+        self.widget.attr_boxes.set_state([(domainA[-2], domainB[-1])])
         self.widget.commit()
         self.assertTablesEqual(self.get_output(self.widget.Outputs.data), result)
 
@@ -384,9 +705,8 @@ def test_output_merge_by_meta_inner(self):
 
         self.send_signal(self.widget.Inputs.data, self.dataA)
         self.send_signal(self.widget.Inputs.extra_data, self.dataB)
-        self.widget.attr_merge_data = domainA[-2]
-        self.widget.attr_merge_extra = domainB[-1]
-        self.widget.controls.merging.buttons[1].click()
+        self.widget.attr_boxes.set_state([(domainA[-2], domainB[-1])])
+        self.widget.controls.merging.buttons[self.widget.InnerJoin].click()
         self.assertTablesEqual(self.get_output(self.widget.Outputs.data), result)
 
     def test_output_merge_by_meta_outer(self):
@@ -410,8 +730,7 @@ def test_output_merge_by_meta_outer(self):
 
         self.send_signal(self.widget.Inputs.data, self.dataA)
         self.send_signal(self.widget.Inputs.extra_data, self.dataB)
-        self.widget.attr_combine_data = domainA[-2]
-        self.widget.attr_combine_extra = domainB[-1]
+        self.widget.attr_boxes.set_state([(domainA[-2], domainB[-1])])
         self.widget.controls.merging.buttons[2].click()
         self.assertTablesEqual(self.get_output(self.widget.Outputs.data), result)
 
@@ -424,25 +743,19 @@ def assertTablesEqual(self, table1, table2):
 
     def test_best_match(self):
         """Check default merging attributes setup"""
+        widget = self.widget
         indices = list(range(101))
         indices.pop(26)
         zoo = Table("zoo")[indices]
         zoo_images = Table(test_filename("datasets/zoo-with-images.tab"))
-        self.send_signal(self.widget.Inputs.data, zoo)
-        self.send_signal(self.widget.Inputs.extra_data, zoo_images)
-        self.assertEqual(self.widget.attr_augment_data, zoo.domain[-1])
-        self.assertEqual(self.widget.attr_augment_extra, zoo_images.domain[-1])
-        self.assertEqual(self.widget.attr_merge_data, zoo.domain[-1])
-        self.assertEqual(self.widget.attr_merge_extra, zoo_images.domain[-1])
-        self.assertEqual(self.widget.attr_combine_data, zoo.domain[-1])
-        self.assertEqual(self.widget.attr_combine_extra, zoo_images.domain[-1])
+        self.send_signal(widget.Inputs.data, zoo)
+        self.send_signal(widget.Inputs.extra_data, zoo_images)
+        for i in range(3):
+            self.assertEqual(widget.attr_boxes.current_state(),
+                             [(zoo.domain[-1], zoo_images.domain[-1])],
+                             f"wrong attributes chosen for merge_type={i}")
 
     def test_sparse(self):
-        """
-        Merge should work with sparse.
-        GH-2295
-        GH-2155
-        """
         data = Table("iris")[::25]
         data_ed_dense = Table("titanic")[::300]
         data_ed_sparse = Table("titanic")[::300].to_sparse()
@@ -461,36 +774,169 @@ def test_sparse(self):
         output_sparse.X = output_sparse.X.toarray()
         self.assertTablesEqual(output_dense, output_sparse)
 
-    def test_auto_apply(self):
-        """Check auto apply"""
-        self.send_signal(self.widget.Inputs.data,
-                         self.dataA[:3, [0, "cls", -1]])
-        self.send_signal(self.widget.Inputs.extra_data,
-                         self.dataA[1:, [1, "cls", -2]])
-        self.widget.attr_merge_data = "Source position (index)"
-        self.widget.attr_merge_extra = "Source position (index)"
-        with patch.object(self.widget.Outputs.data, 'send') as send:
-            self.widget.auto_apply = False
-            self.widget.controls.merging.buttons[1].click()
-            send.assert_not_called()
-            self.widget.controls.merging.buttons[0].click()
-            self.widget.auto_apply = True
-            self.widget.controls.merging.buttons[1].click()
-            send.assert_called()
-
     def test_commit_on_new_data(self):
         """Check that disabling auto apply doesn't block on new data"""
         self.widget.auto_apply = False
+        self.widget.merging = 2
         self.send_signal(self.widget.Inputs.data, self.dataA)
         self.send_signal(self.widget.Inputs.extra_data, self.dataB)
         self.assertIsNotNone(self.get_output(self.widget.Outputs.data))
 
-    def test_non_unique_warning(self):
-        self.widget.auto_apply = True
-        self.send_signal(self.widget.Inputs.data, self.dataA)
-        self.send_signal(self.widget.Inputs.extra_data, self.dataB)
-        self.assertFalse(self.widget.Warning.non_unique_variables.is_shown())
-        self.send_signal(self.widget.Inputs.data, Table("iris"))
-        self.assertTrue(self.widget.Warning.non_unique_variables.is_shown())
-        self.send_signal(self.widget.Inputs.data, None)
-        self.assertFalse(self.widget.Warning.non_unique_variables.is_shown())
+    def test_multiple_attributes_left(self):
+        domainA, domainB = self.dataA.domain, self.dataB.domain
+
+        X = np.array([[0, 1], [1, 1], [3, np.nan], [np.nan, 0]])
+        Y = np.array([1, 2, 0, 0])
+        metas = np.array([[0, "a"], [1, "b"], [0, "c"], [0, "d"]])
+        dataA = Table(domainA, X, Y, metas)
+        dataA.name = "dataA"
+
+        X = np.array(
+            [[0, 0], [1, 0], [0, 1], [1, 1], [2, 0], [3, np.nan]])
+        Y = np.array([0, 0, 1, 1, 0, 1])
+        metas = np.array([[0], [1], [0], [1], [1], [1]])
+        dataB = Table(domainB, X, Y, metas)
+        dataB.name = "dataB"
+
+        self.send_signal(self.widget.Inputs.data, dataA)
+        self.send_signal(self.widget.Inputs.extra_data, dataB)
+        self.widget.attr_boxes.set_state(
+            [(domainA[0], domainB[0]), (domainA[1], domainB[1])])
+        self.widget.commit()
+        output = self.get_output(self.widget.Outputs.data)
+
+        self.assertEqual(output.name, dataA.name)
+        np.testing.assert_array_equal(output.ids, dataA.ids)
+        self.assertEqual(output.attributes, dataA.attributes)
+        np.testing.assert_equal(output.X, dataA.X)
+
+    def test_nonunique(self):
+        widget = self.widget
+        x = ContinuousVariable("x")
+        d = DiscreteVariable("d", values=list("abc"))
+        domain = Domain([x, d], [])
+        dataA = Table.from_numpy(
+            domain, np.array([[1.0, 0], [1, 1], [2, 1]]))
+        dataB = Table.from_numpy(
+            domain, np.array([[1.0, 0], [2, 1], [3, 1]]))
+        dataB.ids = dataA.ids
+        self.send_signal(widget.Inputs.data, dataA)
+        self.send_signal(widget.Inputs.extra_data, dataB)
+        widget.merging = widget.InnerJoin
+
+        self.assertFalse(widget.Error.nonunique_left.is_shown())
+        self.assertFalse(widget.Error.nonunique_right.is_shown())
+
+        widget.attr_boxes.set_state([(INSTANCEID, INSTANCEID)])
+        widget.unconditional_commit()
+        self.assertFalse(widget.Error.nonunique_left.is_shown())
+        self.assertFalse(widget.Error.nonunique_right.is_shown())
+        self.assertIsNotNone(self.get_output(widget.Outputs.data))
+
+        widget.attr_boxes.set_state([(INDEX, INDEX)])
+        widget.unconditional_commit()
+        self.assertFalse(widget.Error.nonunique_left.is_shown())
+        self.assertFalse(widget.Error.nonunique_right.is_shown())
+        self.assertIsNotNone(self.get_output(widget.Outputs.data))
+
+        widget.attr_boxes.set_state([(x, x)])
+        widget.unconditional_commit()
+        self.assertTrue(widget.Error.nonunique_left.is_shown())
+        self.assertFalse(widget.Error.nonunique_right.is_shown())
+        self.assertIsNone(self.get_output(widget.Outputs.data))
+
+        widget.merging = widget.LeftJoin
+        widget.unconditional_commit()
+        self.assertFalse(widget.Error.nonunique_left.is_shown())
+        self.assertFalse(widget.Error.nonunique_right.is_shown())
+        self.assertIsNotNone(self.get_output(widget.Outputs.data))
+
+        widget.merging = widget.InnerJoin
+        widget.attr_boxes.set_state([(x, x), (d, d)])
+        widget.unconditional_commit()
+        self.assertFalse(widget.Error.nonunique_left.is_shown())
+        self.assertFalse(widget.Error.nonunique_right.is_shown())
+        self.assertIsNotNone(self.get_output(widget.Outputs.data))
+
+        widget.attr_boxes.set_state([(d, d)])
+        widget.unconditional_commit()
+        self.assertTrue(widget.Error.nonunique_left.is_shown())
+        self.assertTrue(widget.Error.nonunique_right.is_shown())
+        self.assertIsNone(self.get_output(widget.Outputs.data))
+
+        widget.merging = widget.LeftJoin
+        widget.unconditional_commit()
+        self.assertFalse(widget.Error.nonunique_left.is_shown())
+        self.assertTrue(widget.Error.nonunique_right.is_shown())
+        self.assertIsNone(self.get_output(widget.Outputs.data))
+
+        widget.merging = widget.InnerJoin
+        widget.unconditional_commit()
+        self.assertTrue(widget.Error.nonunique_left.is_shown())
+        self.assertTrue(widget.Error.nonunique_right.is_shown())
+        self.assertIsNone(self.get_output(widget.Outputs.data))
+
+        self.send_signal(widget.Inputs.data, None)
+        self.send_signal(widget.Inputs.extra_data, None)
+        self.assertFalse(widget.Error.nonunique_left.is_shown())
+        self.assertFalse(widget.Error.nonunique_right.is_shown())
+        self.assertIsNone(self.get_output(widget.Outputs.data))
+
+    def test_invalide_pairs(self):
+        widget = self.widget
+        x = ContinuousVariable("x")
+        d = DiscreteVariable("d", values=list("abc"))
+        domain = Domain([x, d], [])
+        dataA = Table.from_numpy(
+            domain, np.array([[1.0, 0], [1, 1], [2, 1]]))
+        dataB = Table.from_numpy(
+            domain, np.array([[1.0, 0], [2, 1], [3, 1]]))
+        dataB.ids = dataA.ids
+        self.send_signal(widget.Inputs.data, dataA)
+        self.send_signal(widget.Inputs.extra_data, dataB)
+
+        widget.attr_boxes.set_state([(x, x), (d, d)])
+        widget.unconditional_commit()
+        self.assertFalse(widget.Error.matching_id_with_sth.is_shown())
+        self.assertFalse(widget.Error.matching_index_with_sth.is_shown())
+        self.assertFalse(widget.Error.matching_numeric_with_nonnum.is_shown())
+
+        widget.attr_boxes.set_state([(x, x), (INDEX, d)])
+        widget.unconditional_commit()
+        self.assertFalse(widget.Error.matching_id_with_sth.is_shown())
+        self.assertTrue(widget.Error.matching_index_with_sth.is_shown())
+        self.assertFalse(widget.Error.matching_numeric_with_nonnum.is_shown())
+
+        widget.attr_boxes.set_state([(x, x), (d, INDEX)])
+        widget.unconditional_commit()
+        self.assertFalse(widget.Error.matching_id_with_sth.is_shown())
+        self.assertTrue(widget.Error.matching_index_with_sth.is_shown())
+        self.assertFalse(widget.Error.matching_numeric_with_nonnum.is_shown())
+
+        widget.attr_boxes.set_state([(x, x), (INSTANCEID, d)])
+        widget.unconditional_commit()
+        self.assertTrue(widget.Error.matching_id_with_sth.is_shown())
+        self.assertFalse(widget.Error.matching_index_with_sth.is_shown())
+        self.assertFalse(widget.Error.matching_numeric_with_nonnum.is_shown())
+
+        widget.attr_boxes.set_state([(x, x), (d, INSTANCEID)])
+        widget.unconditional_commit()
+        self.assertTrue(widget.Error.matching_id_with_sth.is_shown())
+        self.assertFalse(widget.Error.matching_index_with_sth.is_shown())
+        self.assertFalse(widget.Error.matching_numeric_with_nonnum.is_shown())
+
+        widget.attr_boxes.set_state([(x, x), (INDEX, INSTANCEID)])
+        widget.unconditional_commit()
+        self.assertTrue(widget.Error.matching_id_with_sth.is_shown()
+                        or widget.Error.matching_index_with_sth.is_shown())
+        self.assertFalse(widget.Error.matching_numeric_with_nonnum.is_shown())
+
+        widget.attr_boxes.set_state([(x, x), (x, d)])
+        widget.unconditional_commit()
+        self.assertFalse(widget.Error.matching_id_with_sth.is_shown())
+        self.assertFalse(widget.Error.matching_index_with_sth.is_shown())
+        self.assertTrue(widget.Error.matching_numeric_with_nonnum.is_shown())
+
+
+if __name__ == "__main__":
+    unittest.main()