Repository: biolab/orange3

Issue:
Domain: Prevent constructing domain with non-unique variable names
**Is your feature request related to a problem? Please describe.**
We have an agreement to construct domains with unique variable names, but there are no checks for that in the code.

**Describe the solution you'd like**
Raise an error when constructing a domain with duplicated variable names.

**Describe alternatives you've considered**
Create unique variable names under the hood when constructing the domain.



Discussion:
I support raising an exception because we should prefer failing early. The cost of this will be unhandled exceptions at various places, but this is better than unreported errors in various places (and miscomputations later).

I don't like the under-the-hood idea.


Before we accept this enhancement, it is necessary to fix all widgets that may construct a new domain (which seems impossible).

Resolving patch:
diff --git a/Orange/widgets/data/owpaintdata.py b/Orange/widgets/data/owpaintdata.py
--- a/Orange/widgets/data/owpaintdata.py
+++ b/Orange/widgets/data/owpaintdata.py
@@ -21,7 +21,8 @@
 
 import pyqtgraph as pg
 
-import Orange.data
+from Orange.data import ContinuousVariable, DiscreteVariable, Domain, Table
+from Orange.data.util import get_unique_names_duplicates
 
 from Orange.widgets import gui
 from Orange.widgets.settings import Setting
@@ -743,10 +744,10 @@ class OWPaintData(OWWidget):
     keywords = ["create", "draw"]
 
     class Inputs:
-        data = Input("Data", Orange.data.Table)
+        data = Input("Data", Table)
 
     class Outputs:
-        data = Output("Data", Orange.data.Table)
+        data = Output("Data", Table)
 
     autocommit = Setting(True)
     table_name = Setting("Painted data")
@@ -768,6 +769,8 @@ class Warning(OWWidget.Warning):
         no_input_variables = Msg("Input data has no variables")
         continuous_target = Msg("Continuous target value can not be used.")
         sparse_not_supported = Msg("Sparse data is ignored.")
+        renamed_vars = Msg("Some variables have been renamed "
+                           "to avoid duplicates.\n{}")
 
     class Information(OWWidget.Information):
         use_first_two = \
@@ -1259,27 +1262,38 @@ def invalidate(self):
         self.commit()
 
     def commit(self):
+        self.Warning.renamed_vars.clear()
+
         if not self.data:
             self.Outputs.data.send(None)
             return
         data = np.array(self.data)
         if self.hasAttr2:
             X, Y = data[:, :2], data[:, 2]
-            attrs = (Orange.data.ContinuousVariable(self.attr1),
-                     Orange.data.ContinuousVariable(self.attr2))
+            proposed = [self.attr1.strip(), self.attr2.strip()]
         else:
             X, Y = data[:, np.newaxis, 0], data[:, 2]
-            attrs = (Orange.data.ContinuousVariable(self.attr1),)
+            proposed = [self.attr1.strip()]
+
         if len(np.unique(Y)) >= 2:
-            domain = Orange.data.Domain(
-                attrs,
-                Orange.data.DiscreteVariable(
-                    "Class", values=tuple(self.class_model))
+            proposed.append("Class")
+            unique_names, renamed = get_unique_names_duplicates(proposed, True)
+            domain = Domain(
+                (map(ContinuousVariable, unique_names[:-1])),
+                DiscreteVariable(
+                    unique_names[-1], values=tuple(self.class_model))
             )
-            data = Orange.data.Table.from_numpy(domain, X, Y)
+            data = Table.from_numpy(domain, X, Y)
         else:
-            domain = Orange.data.Domain(attrs)
-            data = Orange.data.Table.from_numpy(domain, X)
+            unique_names, renamed = get_unique_names_duplicates(proposed, True)
+            domain = Domain(map(ContinuousVariable, unique_names))
+            data = Table.from_numpy(domain, X)
+
+        if renamed:
+            self.Warning.renamed_vars(", ".join(renamed))
+            self.plot.getAxis("bottom").setLabel(unique_names[0])
+            self.plot.getAxis("left").setLabel(unique_names[1])
+
         data.name = self.table_name
         self.Outputs.data.send(data)
 
diff --git a/Orange/widgets/data/owpivot.py b/Orange/widgets/data/owpivot.py
--- a/Orange/widgets/data/owpivot.py
+++ b/Orange/widgets/data/owpivot.py
@@ -1,7 +1,7 @@
 # pylint: disable=missing-docstring
 from typing import Iterable, Set
 from collections import defaultdict
-from itertools import product
+from itertools import product, chain
 
 import numpy as np
 from scipy import sparse as sp
@@ -15,6 +15,7 @@
 from Orange.data import (Table, DiscreteVariable, Variable, Domain,
                          ContinuousVariable)
 from Orange.data.domain import filter_visible
+from Orange.data.util import get_unique_names_duplicates, get_unique_names
 from Orange.data.filter import FilterContinuous, FilterDiscrete, Values
 from Orange.statistics.util import (nanmin, nanmax, nanunique, nansum, nanvar,
                                     nanmean, nanmedian, nanmode, bincount)
@@ -82,6 +83,7 @@ def __init__(self, table: Table, agg_funs: Iterable[Functions],
         self._table = table
         self._row_var = row_var
         self._col_var = col_var if col_var else row_var
+        self.renamed = []
 
         if not table:
             return
@@ -252,7 +254,19 @@ def __get_group_table(self, var, var_indep_funs, var_dep_funs, attrs):
                     X[:, k] = group_tab.X[:, self._depen_agg_done[fun][v] - offset]
                 else:
                     X[i, k] = fun(sub_table.get_column_view(v)[0])
-        return Table(Domain(leading_vars + attrs), np.hstack((combs, X)))
+
+        #rename leading vars (seems the easiest) if needed
+        current = [var.name for var in attrs]
+        uniq_leading_vars = []
+        for v in leading_vars:
+            uniq = get_unique_names(current, v.name)
+            if uniq != v.name:
+                self.renamed.append(v.name)
+                v = v.copy(name=uniq)
+            uniq_leading_vars.append(v)
+            current.append(uniq)
+
+        return Table(Domain(uniq_leading_vars + attrs), np.hstack((combs, X)))
 
     def update_pivot_table(self, val_var: Variable):
         self._create_pivot_tables(val_var)
@@ -293,8 +307,35 @@ def map_values(index, _X):
                               for i, v in enumerate(vals, 2)])
             for x in (X_v, X_t):
                 attrs.append([DiscreteVariable("Total", map_values(0, x))])
-        row_var_h = DiscreteVariable(self._row_var.name, values=("Total", ))
-        aggr_attr = DiscreteVariable("Aggregate", [str(f) for f in agg_funs])
+        row_var_h = DiscreteVariable(self._row_var.name, values=["Total"])
+        aggr_attr = DiscreteVariable('Aggregate', [str(f) for f in agg_funs])
+
+        change_domain = self._col_var == self._row_var
+
+        extra_vars = [self._row_var, aggr_attr]
+        uniq_a = get_unique_names_duplicates([v.name for v in extra_vars]
+                                             + [atr.name for atr in attrs[0]])
+        for (idx, var), u in zip(enumerate(chain(extra_vars, attrs[0])), uniq_a):
+            if var.name == u:
+                continue
+            if idx == 0:
+                self.renamed.append(self._row_var.name)
+                self._row_var = self._row_var.copy(name=u)
+                row_var_h = row_var_h.copy(name=u)
+            elif idx == 1:
+                self.renamed.append(aggr_attr.name)
+                aggr_attr = aggr_attr.copy(name=u)
+            else:
+                self.renamed.append(var.name)
+                attrs[0][idx-2] = var.copy(name=u)
+                attrs[1][idx-2] = var.copy(name=u)
+
+        if change_domain:
+            vals = tuple(v.name for v in attrs[0])
+            self._row_var.make(self._row_var.name, values=vals)
+            vals = tuple(v.name for v in attrs[2])
+            row_var_h.make(row_var_h.name, vals)
+
         return (Domain([self._row_var, aggr_attr] + attrs[0]),
                 Domain([row_var_h, aggr_attr] + attrs[1]),
                 Domain(attrs[2]), Domain(attrs[3]))
@@ -664,6 +705,8 @@ class Warning(OWWidget.Warning):
         # TODO - inconsistent for different variable types
         no_col_feature = Msg("Column feature should be selected.")
         cannot_aggregate = Msg("Some aggregations ({}) cannot be performed.")
+        renamed_vars = Msg("Some variables have been renamed in some tables"
+                           "to avoid duplicates.\n{}")
 
     settingsHandler = DomainContextHandler()
     row_feature = ContextSetting(None)
@@ -813,6 +856,7 @@ def init_attr_values(self):
                 if domain.variables[0] in model else model[2]
 
     def commit(self):
+        self.Warning.renamed_vars.clear()
         if self.pivot is None:
             self.Warning.no_col_feature.clear()
             if self.no_col_feature:
@@ -834,6 +878,9 @@ def commit(self):
         details = format_summary_details(filtered_data) if filtered_data else ""
         self.info.set_output_summary(summary, details)
 
+        if self.pivot.renamed:
+            self.Warning.renamed_vars(self.pivot.renamed)
+
     def _update_graph(self):
         self.table_view.clear()
         if self.pivot.pivot_table:


Test patch:
diff --git a/Orange/widgets/data/tests/test_owpaintdata.py b/Orange/widgets/data/tests/test_owpaintdata.py
--- a/Orange/widgets/data/tests/test_owpaintdata.py
+++ b/Orange/widgets/data/tests/test_owpaintdata.py
@@ -28,6 +28,17 @@ def test_empty_data(self):
         self.send_signal(self.widget.Inputs.data,
                          Table.from_domain(data.domain))
 
+    def test_var_name_duplicates(self):
+        data = Table("iris")
+        self.send_signal(self.widget.Inputs.data, data)
+        self.widget.attr1 = 'atr1'
+        self.widget.attr2 = 'atr1'
+        self.widget._attr_name_changed()
+        self.assertTrue(self.widget.Warning.renamed_vars.is_shown())
+        self.widget.attr2 = 'atr2'
+        self.widget._attr_name_changed()
+        self.assertFalse(self.widget.Warning.renamed_vars.is_shown())
+
     def test_nan_data(self):
         data = datasets.missing_data_2()
         self.send_signal(self.widget.Inputs.data, data)
diff --git a/Orange/widgets/data/tests/test_owpivot.py b/Orange/widgets/data/tests/test_owpivot.py
--- a/Orange/widgets/data/tests/test_owpivot.py
+++ b/Orange/widgets/data/tests/test_owpivot.py
@@ -259,6 +259,15 @@ def test_summary(self):
         output_sum.assert_called_once()
         self.assertEqual(output_sum.call_args[0][0].brief, "")
 
+    def test_renaming_warning(self):
+        data = Table('iris')
+        cls_var = data.domain.class_var.copy(name='Aggregate')
+        data.domain = Domain(data.domain.attributes, (cls_var,))
+        self.send_signal(self.widget.Inputs.data, data)
+        self.assertTrue(self.widget.Warning.renamed_vars.is_shown())
+
+        self.send_signal(self.widget.Inputs.data, self.iris)
+        self.assertFalse(self.widget.Warning.renamed_vars.is_shown())
 
 class TestAggregationFunctionsEnum(unittest.TestCase):
     def test_pickle(self):
@@ -620,6 +629,17 @@ def test_pivot_data_subset(self):
         domain = Domain(atts)
         self.assert_domain_equal(domain, pivot.pivot_table.domain)
 
+    def test_pivot_renaming_domain(self):
+        data = Table("iris")
+        cls_var = data.domain.class_var.copy(name='Aggregate')
+        data.domain = Domain(data.domain.attributes, (cls_var,))
+        pivot = Pivot(data, [Pivot.Functions.Sum], cls_var, None, None)
+
+        renamed_var = data.domain.class_var.copy(name='Aggregate (1)')
+        self.assertTrue(renamed_var in pivot.pivot_table.domain)
+        renamed_var = data.domain.class_var.copy(name='Aggregate (2)')
+        self.assertTrue(renamed_var in pivot.pivot_table.domain)
+
     def assert_table_equal(self, table1, table2):
         self.assert_domain_equal(table1.domain, table2.domain)
         np.testing.assert_array_equal(table1.X, table2.X)