Repository: ibis-project/ibis

Issue:
bug: relocate not working as expected
### What happened?

setup code -- load the penguins table on Ibis installed on master:

```python
[ins] In [1]: import ibis
[ins] In [2]: import ibis.selectors as s
[ins] In [3]: ibis.options.interactive = True
[ins] In [4]: ibis.options.repr.interactive.max_rows = 3
[ins] In [5]: t = ibis.examples.penguins.fetch()

[ins] In [6]: t
Out[6]:
┏━━━━━━━━━┳━━━━━━━━━━━┳━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━┳━━━━━━━━┳━━━━━━━┓
┃ species ┃ island    ┃ bill_length_mm ┃ bill_depth_mm ┃ flipper_length_mm ┃ body_mass_g ┃ sex    ┃ year  ┃
┡━━━━━━━━━╇━━━━━━━━━━━╇━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━╇━━━━━━━━╇━━━━━━━┩
│ string  │ string    │ float64        │ float64       │ int64             │ int64       │ string │ int64 │
├─────────┼───────────┼────────────────┼───────────────┼───────────────────┼─────────────┼────────┼───────┤
│ Adelie  │ Torgersen │           39.1 │          18.7 │               181 │        3750 │ male   │  2007 │
│ Adelie  │ Torgersen │           39.5 │          17.4 │               186 │        3800 │ female │  2007 │
│ Adelie  │ Torgersen │           40.3 │          18.0 │               195 │        3250 │ female │  2007 │
│ …       │ …         │              … │             … │                 … │           … │ …      │     … │
└─────────┴───────────┴────────────────┴───────────────┴───────────────────┴─────────────┴────────┴───────┘
```

I would expect this to put "year", followed by numeric columns -- instead, numeric columns are first:

```python
[ins] In [7]: t.relocate("year", s.numeric())
Out[7]:
┏━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━┳━━━━━━━┳━━━━━━━━━┳━━━━━━━━━━━┳━━━━━━━━┓
┃ bill_length_mm ┃ bill_depth_mm ┃ flipper_length_mm ┃ body_mass_g ┃ year  ┃ species ┃ island    ┃ sex    ┃
┡━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━╇━━━━━━━╇━━━━━━━━━╇━━━━━━━━━━━╇━━━━━━━━┩
│ float64        │ float64       │ int64             │ int64       │ int64 │ string  │ string    │ string │
├────────────────┼───────────────┼───────────────────┼─────────────┼───────┼─────────┼───────────┼────────┤
│           39.1 │          18.7 │               181 │        3750 │  2007 │ Adelie  │ Torgersen │ male   │
│           39.5 │          17.4 │               186 │        3800 │  2007 │ Adelie  │ Torgersen │ female │
│           40.3 │          18.0 │               195 │        3250 │  2007 │ Adelie  │ Torgersen │ female │
│              … │             … │                 … │           … │     … │ …       │ …         │ …      │
└────────────────┴───────────────┴───────────────────┴─────────────┴───────┴─────────┴───────────┴────────┘
```

I would then expect this to do the opposite:

```python
[nav] In [8]: t.relocate(s.numeric(), "year")
Out[8]:
┏━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━┳━━━━━━━┳━━━━━━━━━┳━━━━━━━━━━━┳━━━━━━━━┓
┃ bill_length_mm ┃ bill_depth_mm ┃ flipper_length_mm ┃ body_mass_g ┃ year  ┃ species ┃ island    ┃ sex    ┃
┡━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━╇━━━━━━━╇━━━━━━━━━╇━━━━━━━━━━━╇━━━━━━━━┩
│ float64        │ float64       │ int64             │ int64       │ int64 │ string  │ string    │ string │
├────────────────┼───────────────┼───────────────────┼─────────────┼───────┼─────────┼───────────┼────────┤
│           39.1 │          18.7 │               181 │        3750 │  2007 │ Adelie  │ Torgersen │ male   │
│           39.5 │          17.4 │               186 │        3800 │  2007 │ Adelie  │ Torgersen │ female │
│           40.3 │          18.0 │               195 │        3250 │  2007 │ Adelie  │ Torgersen │ female │
│              … │             … │                 … │           … │     … │ …       │ …         │ …      │
└────────────────┴───────────────┴───────────────────┴─────────────┴───────┴─────────┴───────────┴────────┘
```

### What version of ibis are you using?

master

### What backend(s) are you using, if any?

duckdb

### Relevant log output

```sh
n/a, see above
```


### Code of Conduct

- [X] I agree to follow this project's Code of Conduct


Discussion:
this works as expected:

```python
[ins] In [9]: t.relocate("year", s.of_type("str"))
Out[9]:
┏━━━━━━━┳━━━━━━━━━┳━━━━━━━━━━━┳━━━━━━━━┳━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━┓
┃ year  ┃ species ┃ island    ┃ sex    ┃ bill_length_mm ┃ bill_depth_mm ┃ flipper_length_mm ┃ body_mass_g ┃
┡━━━━━━━╇━━━━━━━━━╇━━━━━━━━━━━╇━━━━━━━━╇━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━┩
│ int64 │ string  │ string    │ string │ float64        │ float64       │ int64             │ int64       │
├───────┼─────────┼───────────┼────────┼────────────────┼───────────────┼───────────────────┼─────────────┤
│  2007 │ Adelie  │ Torgersen │ male   │           39.1 │          18.7 │               181 │        3750 │
│  2007 │ Adelie  │ Torgersen │ female │           39.5 │          17.4 │               186 │        3800 │
│  2007 │ Adelie  │ Torgersen │ female │           40.3 │          18.0 │               195 │        3250 │
│     … │ …       │ …         │ …      │              … │             … │                 … │           … │
└───────┴─────────┴───────────┴────────┴────────────────┴───────────────┴───────────────────┴─────────────┘

[ins] In [10]: t.relocate(s.of_type("str"), "year")
Out[10]:
┏━━━━━━━━━┳━━━━━━━━━━━┳━━━━━━━━┳━━━━━━━┳━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━┓
┃ species ┃ island    ┃ sex    ┃ year  ┃ bill_length_mm ┃ bill_depth_mm ┃ flipper_length_mm ┃ body_mass_g ┃
┡━━━━━━━━━╇━━━━━━━━━━━╇━━━━━━━━╇━━━━━━━╇━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━┩
│ string  │ string    │ string │ int64 │ float64        │ float64       │ int64             │ int64       │
├─────────┼───────────┼────────┼───────┼────────────────┼───────────────┼───────────────────┼─────────────┤
│ Adelie  │ Torgersen │ male   │  2007 │           39.1 │          18.7 │               181 │        3750 │
│ Adelie  │ Torgersen │ female │  2007 │           39.5 │          17.4 │               186 │        3800 │
│ Adelie  │ Torgersen │ female │  2007 │           40.3 │          18.0 │               195 │        3250 │
│ …       │ …         │ …      │     … │              … │             … │                 … │           … │
└─────────┴───────────┴────────┴───────┴────────────────┴───────────────┴───────────────────┴─────────────┘
```
`relocate` requires passing in `before=` or `after=` keywords if you want to specify a specific ordering of one groups of columns w.r.t. another.
positional arguments are treated as a sequential list of columns to relocate -- in the case of `year, s.numeric()` it's first putting `year` at the front, then it's putting all of the `numeric` columns at the front (and the numeric columns get sorted alphabetically, putting `year` at the end).

if you choose, say, `flipper_length_mm` (which isn't already at the "end" of the numeric columns, you can see what's happening better):

```python

In [10]: t.relocate("flipper_length_mm", s.numeric())  # place flipper at front, then all numeric columns
Out[10]: 
┏━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━┳━━━━━━━┳━━━━━━━━━┳━━━┓
┃ bill_length_mm ┃ bill_depth_mm ┃ flipper_length_mm ┃ body_mass_g ┃ year  ┃ species ┃ … ┃
┡━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━╇━━━━━━━╇━━━━━━━━━╇━━━┩
│ float64        │ float64       │ int64             │ int64       │ int64 │ string  │ … │
├────────────────┼───────────────┼───────────────────┼─────────────┼───────┼─────────┼───┤
│           39.1 │          18.7 │               181 │        3750 │  2007 │ Adelie  │ … │
│           39.5 │          17.4 │               186 │        3800 │  2007 │ Adelie  │ … │
│           40.3 │          18.0 │               195 │        3250 │  2007 │ Adelie  │ … │
│              … │             … │                 … │           … │     … │ …       │ … │
└────────────────┴───────────────┴───────────────────┴─────────────┴───────┴─────────┴───┘

In [11]: t.relocate(s.numeric(), "flipper_length_mm")  # place all numeric columns at front, then flipper
Out[11]: 
┏━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━┳━━━━━━━┳━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━┳━━━┓
┃ bill_length_mm ┃ bill_depth_mm ┃ body_mass_g ┃ year  ┃ flipper_length_mm ┃ species ┃ … ┃
┡━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━╇━━━━━━━╇━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━╇━━━┩
│ float64        │ float64       │ int64       │ int64 │ int64             │ string  │ … │
├────────────────┼───────────────┼─────────────┼───────┼───────────────────┼─────────┼───┤
│           39.1 │          18.7 │        3750 │  2007 │               181 │ Adelie  │ … │
│           39.5 │          17.4 │        3800 │  2007 │               186 │ Adelie  │ … │
│           40.3 │          18.0 │        3250 │  2007 │               195 │ Adelie  │ … │
│              … │             … │           … │     … │                 … │ …       │ … │
└────────────────┴───────────────┴─────────────┴───────┴───────────────────┴─────────┴───┘
```
Hm, I can't remember why the numerics are sorted. Lemme take a look.
Ah, I think this is an artifact of duplicate handling.
I think this is correct behavior. Consider this:

```python
t = ibis.table(dict(x="int", y="int"))
expr = t.relocate("y", s.numeric())
```

`expr` is equivalent to

```python
t.relocate("y", "x", "y")
```

And the behavior of `relocate` in the presence of duplicates is to keep the last one.
I can document this and then close this issue.

I will mention duplicates and also how to avoid this somewhat surprising behavior.
Hm, okay it seems that dplyr handles this case in the intuitive way. Lemme take a closer look.
Yeah, I just realized that `numeric` isn't sorting anything, but it _is_ preserving the existing order of the numeric columns _before_ the initial relocate
Ah, it looks like the behavior is:

- if there are any duplicate **renamings** like `a=x, b=x`, keep only the last one.
- if there are duplicates, not renamed, preserve the selector position.

Resolving patch:
diff --git a/ibis/expr/types/relations.py b/ibis/expr/types/relations.py
--- a/ibis/expr/types/relations.py
+++ b/ibis/expr/types/relations.py
@@ -3898,6 +3898,33 @@ def relocate(
         ├───────┼───────┼────────┼────────┤
         │     1 │     1 │ a      │ a      │
         └───────┴───────┴────────┴────────┘
+
+        When there are duplicate **renames** in a call to relocate, the
+        last one is preserved
+
+        >>> t.relocate(e="d", f="d")
+        ┏━━━━━━━━┳━━━━━━━┳━━━━━━━━┳━━━━━━━┓
+        ┃ f      ┃ a     ┃ b      ┃ c     ┃
+        ┡━━━━━━━━╇━━━━━━━╇━━━━━━━━╇━━━━━━━┩
+        │ string │ int64 │ string │ int64 │
+        ├────────┼───────┼────────┼───────┤
+        │ a      │     1 │ a      │     1 │
+        └────────┴───────┴────────┴───────┘
+
+        However, if there are duplicates that are **not** part of a rename, the
+        order specified in the relocate call is preserved
+
+        >>> t.relocate(
+        ...     "b",
+        ...     s.of_type("string"),  # "b" is a string column, so the selector matches
+        ... )
+        ┏━━━━━━━━┳━━━━━━━━┳━━━━━━━┳━━━━━━━┓
+        ┃ b      ┃ d      ┃ a     ┃ c     ┃
+        ┡━━━━━━━━╇━━━━━━━━╇━━━━━━━╇━━━━━━━┩
+        │ string │ string │ int64 │ int64 │
+        ├────────┼────────┼───────┼───────┤
+        │ a      │ a      │     1 │     1 │
+        └────────┴────────┴───────┴───────┘
         """
         import ibis.selectors as s
 
@@ -3917,11 +3944,13 @@ def relocate(
             zip(kwargs.keys(), map(s._to_selector, kwargs.values())),
         ):
             for pos in sel.positions(self):
-                if pos in sels:
-                    # make sure the last duplicate column wins by reinserting
-                    # the position if it already exists
+                renamed = name is not None
+                if pos in sels and renamed:
+                    # **only when renaming**: make sure the last duplicate
+                    # column wins by reinserting the position if it already
+                    # exists
                     del sels[pos]
-                sels[pos] = name if name is not None else table_columns[pos]
+                sels[pos] = name if renamed else table_columns[pos]
 
         ncols = len(table_columns)
 


Test patch:
diff --git a/ibis/tests/expr/test_relocate.py b/ibis/tests/expr/test_relocate.py
--- a/ibis/tests/expr/test_relocate.py
+++ b/ibis/tests/expr/test_relocate.py
@@ -19,6 +19,12 @@ def test_move_blocks():
     assert t.relocate(s.of_type("string"), after=s.numeric()).columns == list("xyab")
 
 
+def test_duplicates_not_renamed():
+    t = ibis.table(dict(x="int", y="int"))
+    assert t.relocate("y", s.numeric()).columns == list("yx")
+    assert t.relocate("y", s.numeric(), "y").columns == list("yx")
+
+
 def test_keep_non_contiguous_variables():
     t = ibis.table(dict.fromkeys("abcde", "int"))
     assert t.relocate("b", after=s.c("a", "c", "e")).columns == list("acdeb")