Repository: OpenRefine/OpenRefine (java)

Issue:
Better generalizability for the column move command
The column menu offers 4 ways to move a column:

* move column to beginning
* move column to end
* move column to left
* move column to right

![screenshot of those menu entries with their icons](https://github.com/user-attachments/assets/4ecf1998-4606-4974-b326-4ed6aa092dca)

Those 4 options actually trigger the same operation, `column-move`, which takes the name of the column to move and its new index as parameters.
This has the downside that when replayed as part of a recipe, those operations might have a behaviour that's different from the original intent of the user. For instance, say column `foo` was originally at index 4, and I click "Move column right", getting it at index 5. I extract the operation history as a recipe and then reapply this recipe on a project where `foo` is at index 7. The operation will move it to index 5, so moving it two steps left instead of one step right. As a user, I only clicked on "Move column right" and didn't specify any index, so this behaviour is confusing.

Another issue is that the 4 different icons associated with this operation cannot be shown in the operation history, since those 4 menu entries are related to the same operation.


### Proposed solution

Introduce four different new operations which match the descriptions in the menu entries, and only take the column name as parameter. The existing operation is retained for backwards compatibility.

### Alternatives considered

Moving columns via this column menu isn't exactly convenient, especially when you need to do it multiple times. People often prefer to use the "reorder-columns" operation, which also suffers from generalizability issues (https://github.com/OpenRefine/OpenRefine/issues/5576). I intend to tackle those separately.

### Additional context

Tackled as part of the reproducibility project.


Pull request description:
Improve generalizability of the "column move" operation
Fixes #7147.


The Cypress tests will fail because they depend on #7173.
 This is because I didn't introduce new Command classes for the new operations (as it would mean 4 more commands and 4 more command tests), but instead used the `apply-operations` command. I intend to propose to migrate to using `apply-operations` everywhere and deprecate the operation commands in the future.

Patch:
diff --git a/main/resources/com/google/refine/operations/OperationDescription.properties b/main/resources/com/google/refine/operations/OperationDescription.properties
index 182ae9efc..e1e39b3ea 100644
--- a/main/resources/com/google/refine/operations/OperationDescription.properties
+++ b/main/resources/com/google/refine/operations/OperationDescription.properties
@@ -18,6 +18,10 @@ cell_transpose_rows_into_columns_brief=Transpose every {0} cells in column {1} i
 column_addition_by_fetching_urls_brief=Create column {0} at index {1} by fetching URLs based on column {2} using expression {3}
 column_addition_brief=Create column {0} at index {1} based on column {2} using expression {3}
 column_move_brief=Move column {0} to position {1}
+column_move_left_brief=Move column {0} to the left
+column_move_right_brief=Move column {0} to the right
+column_move_first_brief=Move column {0} to the beginning
+column_move_last_brief=Move column {0} to the end
 column_removal_brief=Remove column {0}
 column_rename_brief=Rename column {0} to {1}
 column_reorder_brief=Reorder columns
diff --git a/main/src/com/google/refine/operations/cell/MultiValuedCellJoinOperation.java b/main/src/com/google/refine/operations/cell/MultiValuedCellJoinOperation.java
index 9730538f4..ed65d5b4b 100644
--- a/main/src/com/google/refine/operations/cell/MultiValuedCellJoinOperation.java
+++ b/main/src/com/google/refine/operations/cell/MultiValuedCellJoinOperation.java
@@ -94,7 +94,7 @@ public class MultiValuedCellJoinOperation extends AbstractOperation {
 
     @Override
     public Optional<Set<String>> getColumnDependencies() {
-        return Optional.of(Set.of(_columnName, _keyColumnName));
+        return Optional.of(_columnName.equals(_keyColumnName) ? Set.of(_columnName) : Set.of(_columnName, _keyColumnName));
     }
 
     @Override
diff --git a/main/src/com/google/refine/operations/cell/MultiValuedCellSplitOperation.java b/main/src/com/google/refine/operations/cell/MultiValuedCellSplitOperation.java
index cfba9de77..d1d9b6ed1 100644
--- a/main/src/com/google/refine/operations/cell/MultiValuedCellSplitOperation.java
+++ b/main/src/com/google/refine/operations/cell/MultiValuedCellSplitOperation.java
@@ -181,7 +181,7 @@ public class MultiValuedCellSplitOperation extends AbstractOperation {
 
     @Override
     public Optional<Set<String>> getColumnDependencies() {
-        return Optional.of(Set.of(_columnName, _keyColumnName));
+        return Optional.of(_columnName.equals(_keyColumnName) ? Set.of(_columnName) : Set.of(_columnName, _keyColumnName));
     }
 
     @Override
diff --git a/main/src/com/google/refine/operations/column/AbstractColumnMoveOperation.java b/main/src/com/google/refine/operations/column/AbstractColumnMoveOperation.java
new file mode 100644
index 000000000..368f49c0d
--- /dev/null
+++ b/main/src/com/google/refine/operations/column/AbstractColumnMoveOperation.java
@@ -0,0 +1,63 @@
+
+package com.google.refine.operations.column;
+
+import java.util.Optional;
+import java.util.Set;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+import org.apache.commons.lang.Validate;
+
+import com.google.refine.history.Change;
+import com.google.refine.history.HistoryEntry;
+import com.google.refine.model.AbstractOperation;
+import com.google.refine.model.ColumnsDiff;
+import com.google.refine.model.Project;
+import com.google.refine.model.changes.ColumnMoveChange;
+
+/**
+ * Base class for operations moving a single column to a new position.
+ */
+abstract class AbstractColumnMoveOperation extends AbstractOperation {
+
+    final protected String _columnName;
+
+    public AbstractColumnMoveOperation(
+            String columnName) {
+        _columnName = columnName;
+    }
+
+    @Override
+    public void validate() {
+        Validate.notNull(_columnName, "Missing column name");
+    }
+
+    @JsonProperty("columnName")
+    public String getColumnName() {
+        return _columnName;
+    }
+
+    protected abstract int getNewColumnIndex(int currentIndex, Project project);
+
+    @Override
+    protected HistoryEntry createHistoryEntry(Project project, long historyEntryID) throws Exception {
+        int index = project.columnModel.getColumnIndexByName(_columnName);
+        if (index == -1) {
+            throw new Exception("No column named " + _columnName);
+        }
+        int newIndex = getNewColumnIndex(index, project);
+
+        Change change = new ColumnMoveChange(_columnName, newIndex);
+
+        return new HistoryEntry(historyEntryID, project, getBriefDescription(null), this, change);
+    }
+
+    @Override
+    public Optional<Set<String>> getColumnDependencies() {
+        return Optional.of(Set.of(_columnName));
+    }
+
+    @Override
+    public Optional<ColumnsDiff> getColumnsDiff() {
+        return Optional.of(ColumnsDiff.empty());
+    }
+}
diff --git a/main/src/com/google/refine/operations/column/ColumnMoveFirstOperation.java b/main/src/com/google/refine/operations/column/ColumnMoveFirstOperation.java
new file mode 100644
index 000000000..b9ed53829
--- /dev/null
+++ b/main/src/com/google/refine/operations/column/ColumnMoveFirstOperation.java
@@ -0,0 +1,37 @@
+
+package com.google.refine.operations.column;
+
+import java.util.Map;
+
+import com.fasterxml.jackson.annotation.JsonCreator;
+import com.fasterxml.jackson.annotation.JsonProperty;
+
+import com.google.refine.model.Project;
+import com.google.refine.operations.OperationDescription;
+
+/**
+ * Moves one column to the left-most position (first column of the project)
+ */
+public class ColumnMoveFirstOperation extends AbstractColumnMoveOperation {
+
+    @JsonCreator
+    public ColumnMoveFirstOperation(
+            @JsonProperty("columnName") String columnName) {
+        super(columnName);
+    }
+
+    @Override
+    protected String getBriefDescription(Project project) {
+        return OperationDescription.column_move_first_brief(_columnName);
+    }
+
+    @Override
+    protected int getNewColumnIndex(int currentIndex, Project project) {
+        return 0;
+    }
+
+    @Override
+    public ColumnMoveFirstOperation renameColumns(Map<String, String> renames) {
+        return new ColumnMoveFirstOperation(renames.getOrDefault(_columnName, _columnName));
+    }
+}
diff --git a/main/src/com/google/refine/operations/column/ColumnMoveLastOperation.java b/main/src/com/google/refine/operations/column/ColumnMoveLastOperation.java
new file mode 100644
index 000000000..1bb4bbf60
--- /dev/null
+++ b/main/src/com/google/refine/operations/column/ColumnMoveLastOperation.java
@@ -0,0 +1,37 @@
+
+package com.google.refine.operations.column;
+
+import java.util.Map;
+
+import com.fasterxml.jackson.annotation.JsonCreator;
+import com.fasterxml.jackson.annotation.JsonProperty;
+
+import com.google.refine.model.Project;
+import com.google.refine.operations.OperationDescription;
+
+/**
+ * Moves a column to the end (last column of the project).
+ */
+public class ColumnMoveLastOperation extends AbstractColumnMoveOperation {
+
+    @JsonCreator
+    public ColumnMoveLastOperation(
+            @JsonProperty("columnName") String columnName) {
+        super(columnName);
+    }
+
+    @Override
+    protected String getBriefDescription(Project project) {
+        return OperationDescription.column_move_last_brief(_columnName);
+    }
+
+    @Override
+    protected int getNewColumnIndex(int currentIndex, Project project) {
+        return project.columnModel.columns.size() - 1;
+    }
+
+    @Override
+    public ColumnMoveLastOperation renameColumns(Map<String, String> renames) {
+        return new ColumnMoveLastOperation(renames.getOrDefault(_columnName, _columnName));
+    }
+}
diff --git a/main/src/com/google/refine/operations/column/ColumnMoveLeftOperation.java b/main/src/com/google/refine/operations/column/ColumnMoveLeftOperation.java
new file mode 100644
index 000000000..864c1ff82
--- /dev/null
+++ b/main/src/com/google/refine/operations/column/ColumnMoveLeftOperation.java
@@ -0,0 +1,37 @@
+
+package com.google.refine.operations.column;
+
+import java.util.Map;
+
+import com.fasterxml.jackson.annotation.JsonCreator;
+import com.fasterxml.jackson.annotation.JsonProperty;
+
+import com.google.refine.model.Project;
+import com.google.refine.operations.OperationDescription;
+
+/**
+ * Moves one column to the left.
+ */
+public class ColumnMoveLeftOperation extends AbstractColumnMoveOperation {
+
+    @JsonCreator
+    public ColumnMoveLeftOperation(
+            @JsonProperty("columnName") String columnName) {
+        super(columnName);
+    }
+
+    @Override
+    protected String getBriefDescription(Project project) {
+        return OperationDescription.column_move_left_brief(_columnName);
+    }
+
+    @Override
+    protected int getNewColumnIndex(int currentIndex, Project project) {
+        return Math.max(0, currentIndex - 1);
+    }
+
+    @Override
+    public ColumnMoveLeftOperation renameColumns(Map<String, String> renames) {
+        return new ColumnMoveLeftOperation(renames.getOrDefault(_columnName, _columnName));
+    }
+}
diff --git a/main/src/com/google/refine/operations/column/ColumnMoveOperation.java b/main/src/com/google/refine/operations/column/ColumnMoveOperation.java
index 0ba909930..567cf59d4 100644
--- a/main/src/com/google/refine/operations/column/ColumnMoveOperation.java
+++ b/main/src/com/google/refine/operations/column/ColumnMoveOperation.java
@@ -34,77 +34,48 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
 package com.google.refine.operations.column;
 
 import java.util.Map;
-import java.util.Optional;
-import java.util.Set;
 
 import com.fasterxml.jackson.annotation.JsonCreator;
 import com.fasterxml.jackson.annotation.JsonProperty;
 import org.apache.commons.lang.Validate;
 
-import com.google.refine.history.Change;
-import com.google.refine.history.HistoryEntry;
-import com.google.refine.model.AbstractOperation;
-import com.google.refine.model.ColumnsDiff;
 import com.google.refine.model.Project;
-import com.google.refine.model.changes.ColumnMoveChange;
 import com.google.refine.operations.OperationDescription;
 
-public class ColumnMoveOperation extends AbstractOperation {
+public class ColumnMoveOperation extends AbstractColumnMoveOperation {
 
-    final protected String _columnName;
     final protected int _index;
 
     @JsonCreator
     public ColumnMoveOperation(
             @JsonProperty("columnName") String columnName,
             @JsonProperty("index") int index) {
-        _columnName = columnName;
+        super(columnName);
         _index = index;
     }
 
     @Override
     public void validate() {
-        Validate.notNull(_columnName, "Missing column name");
+        super.validate();
         Validate.isTrue(_index >= 0, "Invalid column index");
     }
 
-    @JsonProperty("columnName")
-    public String getColumnName() {
-        return _columnName;
-    }
-
     @JsonProperty("index")
     public int getIndex() {
         return _index;
     }
 
     @Override
-    protected String getBriefDescription(Project project) {
-        return OperationDescription.column_move_brief(_columnName, _index);
-    }
-
-    @Override
-    protected HistoryEntry createHistoryEntry(Project project, long historyEntryID) throws Exception {
-        if (project.columnModel.getColumnByName(_columnName) == null) {
-            throw new Exception("No column named " + _columnName);
-        }
+    protected int getNewColumnIndex(int currentIndex, Project project) {
         if (_index < 0 || _index >= project.columnModel.columns.size()) {
-            throw new Exception("New column index out of range " + _index);
+            throw new IllegalArgumentException("New column index out of range " + _index);
         }
-
-        Change change = new ColumnMoveChange(_columnName, _index);
-
-        return new HistoryEntry(historyEntryID, project, getBriefDescription(null), ColumnMoveOperation.this, change);
-    }
-
-    @Override
-    public Optional<Set<String>> getColumnDependencies() {
-        return Optional.of(Set.of(_columnName));
+        return _index;
     }
 
     @Override
-    public Optional<ColumnsDiff> getColumnsDiff() {
-        return Optional.of(ColumnsDiff.empty());
+    protected String getBriefDescription(Project project) {
+        return OperationDescription.column_move_brief(_columnName, _index);
     }
 
     @Override
diff --git a/main/src/com/google/refine/operations/column/ColumnMoveRightOperation.java b/main/src/com/google/refine/operations/column/ColumnMoveRightOperation.java
new file mode 100644
index 000000000..ce58659f8
--- /dev/null
+++ b/main/src/com/google/refine/operations/column/ColumnMoveRightOperation.java
@@ -0,0 +1,37 @@
+
+package com.google.refine.operations.column;
+
+import java.util.Map;
+
+import com.fasterxml.jackson.annotation.JsonCreator;
+import com.fasterxml.jackson.annotation.JsonProperty;
+
+import com.google.refine.model.Project;
+import com.google.refine.operations.OperationDescription;
+
+/**
+ * Moves one column to the right.
+ */
+public class ColumnMoveRightOperation extends AbstractColumnMoveOperation {
+
+    @JsonCreator
+    public ColumnMoveRightOperation(
+            @JsonProperty("columnName") String columnName) {
+        super(columnName);
+    }
+
+    @Override
+    protected String getBriefDescription(Project project) {
+        return OperationDescription.column_move_right_brief(_columnName);
+    }
+
+    @Override
+    protected int getNewColumnIndex(int currentIndex, Project project) {
+        return Math.min(project.columnModel.columns.size() - 1, currentIndex + 1);
+    }
+
+    @Override
+    public ColumnMoveRightOperation renameColumns(Map<String, String> renames) {
+        return new ColumnMoveRightOperation(renames.getOrDefault(_columnName, _columnName));
+    }
+}
diff --git a/main/webapp/modules/core/MOD-INF/controller.js b/main/webapp/modules/core/MOD-INF/controller.js
index c35f1cc37..49ed9f6c3 100644
--- a/main/webapp/modules/core/MOD-INF/controller.js
+++ b/main/webapp/modules/core/MOD-INF/controller.js
@@ -172,6 +172,10 @@ function registerOperations() {
   OR.registerOperation(module, "column-removal", Packages.com.google.refine.operations.column.ColumnRemovalOperation);
   OR.registerOperation(module, "column-rename", Packages.com.google.refine.operations.column.ColumnRenameOperation);
   OR.registerOperation(module, "column-move", Packages.com.google.refine.operations.column.ColumnMoveOperation);
+  OR.registerOperation(module, "column-move-left", Packages.com.google.refine.operations.column.ColumnMoveLeftOperation);
+  OR.registerOperation(module, "column-move-right", Packages.com.google.refine.operations.column.ColumnMoveRightOperation);
+  OR.registerOperation(module, "column-move-first", Packages.com.google.refine.operations.column.ColumnMoveFirstOperation);
+  OR.registerOperation(module, "column-move-last", Packages.com.google.refine.operations.column.ColumnMoveLastOperation);
   OR.registerOperation(module, "column-split", Packages.com.google.refine.operations.column.ColumnSplitOperation);
   OR.registerOperation(module, "column-addition-by-fetching-urls", Packages.com.google.refine.operations.column.ColumnAdditionByFetchingURLsOperation);
   OR.registerOperation(module, "column-reorder", Packages.com.google.refine.operations.column.ColumnReorderOperation);
diff --git a/main/webapp/modules/core/scripts/project/operation-icons.js b/main/webapp/modules/core/scripts/project/operation-icons.js
index 0e46b489f..5414a256f 100644
--- a/main/webapp/modules/core/scripts/project/operation-icons.js
+++ b/main/webapp/modules/core/scripts/project/operation-icons.js
@@ -31,6 +31,10 @@ OperationIconRegistry.setIcon('core/column-addition', 'images/operations/add.svg
 OperationIconRegistry.setIcon('core/column-removal', 'images/operations/delete.svg');
 OperationIconRegistry.setIcon('core/column-rename', 'images/operations/rename.svg');
 OperationIconRegistry.setIcon('core/column-move', 'images/operations/column-move.svg');
+OperationIconRegistry.setIcon('core/column-move-left', 'images/operations/move-left.svg');
+OperationIconRegistry.setIcon('core/column-move-right', 'images/operations/move-right.svg');
+OperationIconRegistry.setIcon('core/column-move-first', 'images/operations/move-first.svg');
+OperationIconRegistry.setIcon('core/column-move-last', 'images/operations/move-last.svg');
 OperationIconRegistry.setIcon('core/column-split', 'images/operations/split-columns.svg');
 OperationIconRegistry.setIcon('core/column-addition-by-fetching-urls', 'images/operations/fetch-urls.svg');
 OperationIconRegistry.setIcon('core/column-reorder', 'images/operations/column-reorder.svg');
diff --git a/main/webapp/modules/core/scripts/views/data-table/menu-edit-column.js b/main/webapp/modules/core/scripts/views/data-table/menu-edit-column.js
index 5cd17b9ea..16a884faa 100644
--- a/main/webapp/modules/core/scripts/views/data-table/menu-edit-column.js
+++ b/main/webapp/modules/core/scripts/views/data-table/menu-edit-column.js
@@ -214,33 +214,20 @@ DataTableColumnHeaderUI.extendMenu(function(column, columnHeaderUI, menu) {
     );
   };
 
-  var doMoveColumnTo = function(index) {
+  var doMoveColumnTo = function(direction) {
     Refine.postCoreProcess(
-      "move-column", 
-      {
-        columnName: column.name,
-        index: index
-      },
-      null,
+      "apply-operations",
+      {},
+      { operations: JSON.stringify([
+        {
+           op: 'core/column-move-' + direction,
+           columnName: column.name
+        }
+      ]) },
       { modelsChanged: true, rowIdsPreserved: true }
     );
   };
 
-  var doMoveColumnBy = function(change) {
-    var newidx = Refine.columnNameToColumnIndex(column.name) + change;
-    if (newidx >= 0) {
-      Refine.postCoreProcess(
-          "move-column", 
-          {
-            columnName: column.name,
-            index: newidx
-          },
-          null,
-          { modelsChanged: true, rowIdsPreserved: true }
-      );
-    }
-  };
-
   var doSplitColumn = function() {
     var frame = $(DOM.loadHTML("core", "scripts/views/data-table/split-column-dialog.html"));
     var elmts = DOM.bind(frame);
@@ -566,25 +553,25 @@ DataTableColumnHeaderUI.extendMenu(function(column, columnHeaderUI, menu) {
         id: "core/move-column-to-beginning",
         label: $.i18n('core-views/move-to-beg'),
         icon: 'images/operations/move-first.svg',
-        click: function() { doMoveColumnTo(0); }
+        click: function() { doMoveColumnTo('first'); }
       },
       {
         id: "core/move-column-to-end",
         label: $.i18n('core-views/move-to-end'),
         icon: 'images/operations/move-last.svg',
-        click: function() { doMoveColumnTo(theProject.columnModel.columns.length - 1); }
+        click: function() { doMoveColumnTo('last'); }
       },
       {
         id: "core/move-column-to-left",
         label: $.i18n('core-views/move-to-left'),
         icon: 'images/operations/move-left.svg',
-        click: function() { doMoveColumnBy(-1);}
+        click: function() { doMoveColumnTo('left');}
       },
       {
         id: "core/move-column-to-right",
         label: $.i18n('core-views/move-to-right'),
         icon: 'images/operations/move-right.svg',
-        click: function() { doMoveColumnBy(1); }
+        click: function() { doMoveColumnTo('right'); }
       }
     ]
   );


Test patch:
diff --git a/main/tests/cypress/cypress/e2e/project/grid/column/edit-column/reposition_column.cy.js b/main/tests/cypress/cypress/e2e/project/grid/column/edit-column/reposition_column.cy.js
index 805ca5753..031c6c5c9 100644
--- a/main/tests/cypress/cypress/e2e/project/grid/column/edit-column/reposition_column.cy.js
+++ b/main/tests/cypress/cypress/e2e/project/grid/column/edit-column/reposition_column.cy.js
@@ -7,7 +7,7 @@ describe(__filename, function () {
       'Move column to beginning',
     ]);
 
-    cy.assertNotificationContainingText('Move column Shrt_Desc to position 0');
+    cy.assertNotificationContainingText('Move column Shrt_Desc to the beginning');
 
     cy.get('.data-table-header th:nth-child(2)').should(
       'to.contain',
@@ -19,7 +19,7 @@ describe(__filename, function () {
 
     cy.columnActionClick('Shrt_Desc', ['Edit column', 'Move column to end']);
 
-    cy.assertNotificationContainingText('Move column Shrt_Desc to position 3');
+    cy.assertNotificationContainingText('Move column Shrt_Desc to the end');
 
     cy.get('.data-table-header th:nth-child(5)').should(
       'to.contain',
@@ -31,7 +31,7 @@ describe(__filename, function () {
 
     cy.columnActionClick('Shrt_Desc', ['Edit column', 'Move column left']);
 
-    cy.assertNotificationContainingText('Move column Shrt_Desc to position 0');
+    cy.assertNotificationContainingText('Move column Shrt_Desc to the left');
 
     cy.get('.data-table-header th:nth-child(2)').should(
       'to.contain',
@@ -43,7 +43,7 @@ describe(__filename, function () {
 
     cy.columnActionClick('Shrt_Desc', ['Edit column', 'Move column right']);
 
-    cy.assertNotificationContainingText('Move column Shrt_Desc to position 2');
+    cy.assertNotificationContainingText('Move column Shrt_Desc to the right');
 
     cy.get('.data-table-header th:nth-child(4)').should(
       'to.contain',
diff --git a/main/tests/server/src/com/google/refine/operations/cell/MultiValuedCellJoinOperationTests.java b/main/tests/server/src/com/google/refine/operations/cell/MultiValuedCellJoinOperationTests.java
index 8a080f030..bf4de400d 100644
--- a/main/tests/server/src/com/google/refine/operations/cell/MultiValuedCellJoinOperationTests.java
+++ b/main/tests/server/src/com/google/refine/operations/cell/MultiValuedCellJoinOperationTests.java
@@ -121,6 +121,11 @@ public class MultiValuedCellJoinOperationTests extends RefineTest {
         assertEquals(new MultiValuedCellJoinOperation("value", "key", "sep").getColumnDependencies().get(), Set.of("value", "key"));
     }
 
+    @Test
+    public void testColumnsDependenciesWithIdenticalColumns() {
+        assertEquals(new MultiValuedCellJoinOperation("key", "key", "sep").getColumnDependencies().get(), Set.of("key"));
+    }
+
     @Test
     public void testRename() {
         var SUT = new MultiValuedCellJoinOperation("value", "key", "sep");
diff --git a/main/tests/server/src/com/google/refine/operations/cell/MultiValuedCellSplitOperationTests.java b/main/tests/server/src/com/google/refine/operations/cell/MultiValuedCellSplitOperationTests.java
index ed1adeb1a..d3f7da64b 100644
--- a/main/tests/server/src/com/google/refine/operations/cell/MultiValuedCellSplitOperationTests.java
+++ b/main/tests/server/src/com/google/refine/operations/cell/MultiValuedCellSplitOperationTests.java
@@ -185,6 +185,15 @@ public class MultiValuedCellSplitOperationTests extends RefineTest {
                 false).getColumnDependencies().get(), Set.of("Value", "Key"));
     }
 
+    @Test
+    public void testColumnsDependenciesWithIdenticalColumns() {
+        assertEquals(new MultiValuedCellSplitOperation(
+                "Key",
+                "Key",
+                ":",
+                false).getColumnDependencies().get(), Set.of("Key"));
+    }
+
     @Test
     public void testRename() {
         var SUT = new MultiValuedCellSplitOperation(
diff --git a/main/tests/server/src/com/google/refine/operations/column/ColumnMoveFirstOperationTests.java b/main/tests/server/src/com/google/refine/operations/column/ColumnMoveFirstOperationTests.java
new file mode 100644
index 000000000..cbaa3f905
--- /dev/null
+++ b/main/tests/server/src/com/google/refine/operations/column/ColumnMoveFirstOperationTests.java
@@ -0,0 +1,111 @@
+
+package com.google.refine.operations.column;
+
+import static org.testng.Assert.assertEquals;
+import static org.testng.Assert.assertThrows;
+
+import java.io.Serializable;
+import java.util.Map;
+import java.util.Set;
+
+import com.fasterxml.jackson.databind.node.TextNode;
+import org.testng.annotations.BeforeMethod;
+import org.testng.annotations.BeforeSuite;
+import org.testng.annotations.Test;
+
+import com.google.refine.RefineTest;
+import com.google.refine.expr.EvalError;
+import com.google.refine.model.AbstractOperation;
+import com.google.refine.model.ColumnsDiff;
+import com.google.refine.model.Project;
+import com.google.refine.operations.OperationDescription;
+import com.google.refine.operations.OperationRegistry;
+import com.google.refine.util.ParsingUtilities;
+import com.google.refine.util.TestUtils;
+
+public class ColumnMoveFirstOperationTests extends RefineTest {
+
+    protected Project project;
+
+    @BeforeMethod
+    public void setUpInitialState() {
+        project = createProject(new String[] { "foo", "bar", "hello" },
+                new Serializable[][] {
+                        { "v1", "a", "d" },
+                        { "v3", "a", "f" },
+                        { "", "a", "g" },
+                        { "", "b", "h" },
+                        { new EvalError("error"), "a", "i" },
+                        { "v1", "b", "j" }
+                });
+    }
+
+    @BeforeSuite
+    public void setUp() {
+        OperationRegistry.registerOperation(getCoreModule(), "column-move-first", ColumnMoveFirstOperation.class);
+    }
+
+    @Test
+    public void serializeColumnMoveFirstOperation() throws Exception {
+        String json = "{\"op\":\"core/column-move-first\","
+                + "\"description\":" + new TextNode(OperationDescription.column_move_first_brief("my column")).toString() + ","
+                + "\"columnName\":\"my column\"}";
+        TestUtils.isSerializedTo(ParsingUtilities.mapper.readValue(json, ColumnMoveFirstOperation.class), json);
+    }
+
+    @Test
+    public void testValidate() {
+        AbstractOperation missingColumnName = new ColumnMoveFirstOperation(null);
+        assertThrows(IllegalArgumentException.class, () -> missingColumnName.validate());
+    }
+
+    @Test
+    public void testMove() throws Exception {
+        ColumnMoveFirstOperation operation = new ColumnMoveFirstOperation("hello");
+        assertEquals(operation.getColumnDependencies().get(), Set.of("hello"));
+        assertEquals(operation.getColumnsDiff().get(), ColumnsDiff.empty());
+
+        runOperation(operation, project);
+
+        Project expected = createProject(new String[] { "hello", "foo", "bar" },
+                new Serializable[][] {
+                        { "d", "v1", "a" },
+                        { "f", "v3", "a" },
+                        { "g", "", "a" },
+                        { "h", "", "b" },
+                        { "i", new EvalError("error"), "a" },
+                        { "j", "v1", "b" }
+                });
+        assertProjectEquals(project, expected);
+    }
+
+    @Test
+    public void testSamePosition() throws Exception {
+        AbstractOperation SUT = new ColumnMoveFirstOperation("foo");
+        assertEquals(SUT.getColumnDependencies().get(), Set.of("foo"));
+        assertEquals(SUT.getColumnsDiff().get(), ColumnsDiff.empty());
+
+        runOperation(SUT, project);
+
+        Project expected = createProject(
+                new String[] { "foo", "bar", "hello" },
+                new Serializable[][] {
+                        { "v1", "a", "d" },
+                        { "v3", "a", "f" },
+                        { "", "a", "g" },
+                        { "", "b", "h" },
+                        { new EvalError("error"), "a", "i" },
+                        { "v1", "b", "j" },
+                });
+        assertProjectEquals(project, expected);
+    }
+
+    @Test
+    public void testRename() {
+        ColumnMoveFirstOperation SUT = new ColumnMoveFirstOperation("hello");
+
+        ColumnMoveFirstOperation renamed = SUT.renameColumns(Map.of("hello", "world"));
+
+        assertEquals(renamed._columnName, "world");
+    }
+}
diff --git a/main/tests/server/src/com/google/refine/operations/column/ColumnMoveLastOperationTests.java b/main/tests/server/src/com/google/refine/operations/column/ColumnMoveLastOperationTests.java
new file mode 100644
index 000000000..318011c01
--- /dev/null
+++ b/main/tests/server/src/com/google/refine/operations/column/ColumnMoveLastOperationTests.java
@@ -0,0 +1,111 @@
+
+package com.google.refine.operations.column;
+
+import static org.testng.Assert.assertEquals;
+import static org.testng.Assert.assertThrows;
+
+import java.io.Serializable;
+import java.util.Map;
+import java.util.Set;
+
+import com.fasterxml.jackson.databind.node.TextNode;
+import org.testng.annotations.BeforeMethod;
+import org.testng.annotations.BeforeSuite;
+import org.testng.annotations.Test;
+
+import com.google.refine.RefineTest;
+import com.google.refine.expr.EvalError;
+import com.google.refine.model.AbstractOperation;
+import com.google.refine.model.ColumnsDiff;
+import com.google.refine.model.Project;
+import com.google.refine.operations.OperationDescription;
+import com.google.refine.operations.OperationRegistry;
+import com.google.refine.util.ParsingUtilities;
+import com.google.refine.util.TestUtils;
+
+public class ColumnMoveLastOperationTests extends RefineTest {
+
+    protected Project project;
+
+    @BeforeMethod
+    public void setUpInitialState() {
+        project = createProject(new String[] { "foo", "bar", "hello" },
+                new Serializable[][] {
+                        { "v1", "a", "d" },
+                        { "v3", "a", "f" },
+                        { "", "a", "g" },
+                        { "", "b", "h" },
+                        { new EvalError("error"), "a", "i" },
+                        { "v1", "b", "j" }
+                });
+    }
+
+    @BeforeSuite
+    public void setUp() {
+        OperationRegistry.registerOperation(getCoreModule(), "column-move-last", ColumnMoveLastOperation.class);
+    }
+
+    @Test
+    public void serializeColumnMoveLastOperation() throws Exception {
+        String json = "{\"op\":\"core/column-move-last\","
+                + "\"description\":" + new TextNode(OperationDescription.column_move_last_brief("my column")).toString() + ","
+                + "\"columnName\":\"my column\"}";
+        TestUtils.isSerializedTo(ParsingUtilities.mapper.readValue(json, ColumnMoveLastOperation.class), json);
+    }
+
+    @Test
+    public void testValidate() {
+        AbstractOperation missingColumnName = new ColumnMoveLastOperation(null);
+        assertThrows(IllegalArgumentException.class, () -> missingColumnName.validate());
+    }
+
+    @Test
+    public void testMove() throws Exception {
+        ColumnMoveLastOperation operation = new ColumnMoveLastOperation("foo");
+        assertEquals(operation.getColumnDependencies().get(), Set.of("foo"));
+        assertEquals(operation.getColumnsDiff().get(), ColumnsDiff.empty());
+
+        runOperation(operation, project);
+
+        Project expected = createProject(new String[] { "bar", "hello", "foo" },
+                new Serializable[][] {
+                        { "a", "d", "v1" },
+                        { "a", "f", "v3" },
+                        { "a", "g", "" },
+                        { "b", "h", "" },
+                        { "a", "i", new EvalError("error") },
+                        { "b", "j", "v1" }
+                });
+        assertProjectEquals(project, expected);
+    }
+
+    @Test
+    public void testSamePosition() throws Exception {
+        AbstractOperation SUT = new ColumnMoveRightOperation("hello");
+        assertEquals(SUT.getColumnDependencies().get(), Set.of("hello"));
+        assertEquals(SUT.getColumnsDiff().get(), ColumnsDiff.empty());
+
+        runOperation(SUT, project);
+
+        Project expected = createProject(
+                new String[] { "foo", "bar", "hello" },
+                new Serializable[][] {
+                        { "v1", "a", "d" },
+                        { "v3", "a", "f" },
+                        { "", "a", "g" },
+                        { "", "b", "h" },
+                        { new EvalError("error"), "a", "i" },
+                        { "v1", "b", "j" },
+                });
+        assertProjectEquals(project, expected);
+    }
+
+    @Test
+    public void testRename() {
+        ColumnMoveLastOperation SUT = new ColumnMoveLastOperation("hello");
+
+        ColumnMoveLastOperation renamed = SUT.renameColumns(Map.of("hello", "world"));
+
+        assertEquals(renamed._columnName, "world");
+    }
+}
diff --git a/main/tests/server/src/com/google/refine/operations/column/ColumnMoveLeftOperationTests.java b/main/tests/server/src/com/google/refine/operations/column/ColumnMoveLeftOperationTests.java
new file mode 100644
index 000000000..5c531a11c
--- /dev/null
+++ b/main/tests/server/src/com/google/refine/operations/column/ColumnMoveLeftOperationTests.java
@@ -0,0 +1,112 @@
+
+package com.google.refine.operations.column;
+
+import static org.testng.Assert.assertEquals;
+import static org.testng.Assert.assertThrows;
+
+import java.io.Serializable;
+import java.util.Map;
+import java.util.Set;
+
+import com.fasterxml.jackson.databind.node.TextNode;
+import org.testng.annotations.BeforeMethod;
+import org.testng.annotations.BeforeSuite;
+import org.testng.annotations.Test;
+
+import com.google.refine.RefineTest;
+import com.google.refine.expr.EvalError;
+import com.google.refine.model.AbstractOperation;
+import com.google.refine.model.ColumnsDiff;
+import com.google.refine.model.Project;
+import com.google.refine.operations.OperationDescription;
+import com.google.refine.operations.OperationRegistry;
+import com.google.refine.util.ParsingUtilities;
+import com.google.refine.util.TestUtils;
+
+public class ColumnMoveLeftOperationTests extends RefineTest {
+
+    protected Project project;
+
+    @BeforeMethod
+    public void setUpInitialState() {
+        project = createProject(new String[] { "foo", "bar", "hello" },
+                new Serializable[][] {
+                        { "v1", "a", "d" },
+                        { "v3", "a", "f" },
+                        { "", "a", "g" },
+                        { "", "b", "h" },
+                        { new EvalError("error"), "a", "i" },
+                        { "v1", "b", "j" }
+                });
+    }
+
+    @BeforeSuite
+    public void setUp() {
+        OperationRegistry.registerOperation(getCoreModule(), "column-move-left", ColumnMoveLeftOperation.class);
+    }
+
+    @Test
+    public void serializeColumnMoveLeftOperation() throws Exception {
+        String json = "{\"op\":\"core/column-move-left\","
+                + "\"description\":" + new TextNode(OperationDescription.column_move_left_brief("my column")).toString() + ","
+                + "\"columnName\":\"my column\"}";
+        TestUtils.isSerializedTo(ParsingUtilities.mapper.readValue(json, ColumnMoveLeftOperation.class), json);
+    }
+
+    @Test
+    public void testValidate() {
+        AbstractOperation missingColumnName = new ColumnMoveLeftOperation(null);
+        assertThrows(IllegalArgumentException.class, () -> missingColumnName.validate());
+    }
+
+    @Test
+    public void testMove() throws Exception {
+        ColumnMoveLeftOperation operation = new ColumnMoveLeftOperation("bar");
+        assertEquals(operation.getColumnDependencies().get(), Set.of("bar"));
+        assertEquals(operation.getColumnsDiff().get(), ColumnsDiff.empty());
+
+        runOperation(operation, project);
+
+        Project expected = createProject(
+                new String[] { "bar", "foo", "hello" },
+                new Serializable[][] {
+                        { "a", "v1", "d" },
+                        { "a", "v3", "f" },
+                        { "a", "", "g" },
+                        { "b", "", "h" },
+                        { "a", new EvalError("error"), "i" },
+                        { "b", "v1", "j" },
+                });
+        assertProjectEquals(project, expected);
+    }
+
+    @Test
+    public void testSamePosition() throws Exception {
+        AbstractOperation SUT = new ColumnMoveLeftOperation("foo");
+        assertEquals(SUT.getColumnDependencies().get(), Set.of("foo"));
+        assertEquals(SUT.getColumnsDiff().get(), ColumnsDiff.empty());
+
+        runOperation(SUT, project);
+
+        Project expected = createProject(
+                new String[] { "foo", "bar", "hello" },
+                new Serializable[][] {
+                        { "v1", "a", "d" },
+                        { "v3", "a", "f" },
+                        { "", "a", "g" },
+                        { "", "b", "h" },
+                        { new EvalError("error"), "a", "i" },
+                        { "v1", "b", "j" },
+                });
+        assertProjectEquals(project, expected);
+    }
+
+    @Test
+    public void testRename() {
+        ColumnMoveLeftOperation SUT = new ColumnMoveLeftOperation("hello");
+
+        ColumnMoveLeftOperation renamed = SUT.renameColumns(Map.of("hello", "world"));
+
+        assertEquals(renamed._columnName, "world");
+    }
+}
diff --git a/main/tests/server/src/com/google/refine/operations/column/ColumnMoveRightOperationTests.java b/main/tests/server/src/com/google/refine/operations/column/ColumnMoveRightOperationTests.java
new file mode 100644
index 000000000..0fe64d7bf
--- /dev/null
+++ b/main/tests/server/src/com/google/refine/operations/column/ColumnMoveRightOperationTests.java
@@ -0,0 +1,112 @@
+
+package com.google.refine.operations.column;
+
+import static org.testng.Assert.assertEquals;
+import static org.testng.Assert.assertThrows;
+
+import java.io.Serializable;
+import java.util.Map;
+import java.util.Set;
+
+import com.fasterxml.jackson.databind.node.TextNode;
+import org.testng.annotations.BeforeMethod;
+import org.testng.annotations.BeforeSuite;
+import org.testng.annotations.Test;
+
+import com.google.refine.RefineTest;
+import com.google.refine.expr.EvalError;
+import com.google.refine.model.AbstractOperation;
+import com.google.refine.model.ColumnsDiff;
+import com.google.refine.model.Project;
+import com.google.refine.operations.OperationDescription;
+import com.google.refine.operations.OperationRegistry;
+import com.google.refine.util.ParsingUtilities;
+import com.google.refine.util.TestUtils;
+
+public class ColumnMoveRightOperationTests extends RefineTest {
+
+    protected Project project;
+
+    @BeforeMethod
+    public void setUpInitialState() {
+        project = createProject(new String[] { "foo", "bar", "hello" },
+                new Serializable[][] {
+                        { "v1", "a", "d" },
+                        { "v3", "a", "f" },
+                        { "", "a", "g" },
+                        { "", "b", "h" },
+                        { new EvalError("error"), "a", "i" },
+                        { "v1", "b", "j" }
+                });
+    }
+
+    @BeforeSuite
+    public void setUp() {
+        OperationRegistry.registerOperation(getCoreModule(), "column-move-right", ColumnMoveRightOperation.class);
+    }
+
+    @Test
+    public void serializeColumnMoveRightOperation() throws Exception {
+        String json = "{\"op\":\"core/column-move-right\","
+                + "\"description\":" + new TextNode(OperationDescription.column_move_right_brief("my column")).toString() + ","
+                + "\"columnName\":\"my column\"}";
+        TestUtils.isSerializedTo(ParsingUtilities.mapper.readValue(json, ColumnMoveRightOperation.class), json);
+    }
+
+    @Test
+    public void testValidate() {
+        AbstractOperation missingColumnName = new ColumnMoveRightOperation(null);
+        assertThrows(IllegalArgumentException.class, () -> missingColumnName.validate());
+    }
+
+    @Test
+    public void testMove() throws Exception {
+        ColumnMoveRightOperation operation = new ColumnMoveRightOperation("foo");
+        assertEquals(operation.getColumnDependencies().get(), Set.of("foo"));
+        assertEquals(operation.getColumnsDiff().get(), ColumnsDiff.empty());
+
+        runOperation(operation, project);
+
+        Project expected = createProject(
+                new String[] { "bar", "foo", "hello" },
+                new Serializable[][] {
+                        { "a", "v1", "d" },
+                        { "a", "v3", "f" },
+                        { "a", "", "g" },
+                        { "b", "", "h" },
+                        { "a", new EvalError("error"), "i" },
+                        { "b", "v1", "j" },
+                });
+        assertProjectEquals(project, expected);
+    }
+
+    @Test
+    public void testSamePosition() throws Exception {
+        AbstractOperation SUT = new ColumnMoveRightOperation("hello");
+        assertEquals(SUT.getColumnDependencies().get(), Set.of("hello"));
+        assertEquals(SUT.getColumnsDiff().get(), ColumnsDiff.empty());
+
+        runOperation(SUT, project);
+
+        Project expected = createProject(
+                new String[] { "foo", "bar", "hello" },
+                new Serializable[][] {
+                        { "v1", "a", "d" },
+                        { "v3", "a", "f" },
+                        { "", "a", "g" },
+                        { "", "b", "h" },
+                        { new EvalError("error"), "a", "i" },
+                        { "v1", "b", "j" },
+                });
+        assertProjectEquals(project, expected);
+    }
+
+    @Test
+    public void testRename() {
+        ColumnMoveRightOperation SUT = new ColumnMoveRightOperation("hello");
+
+        ColumnMoveRightOperation renamed = SUT.renameColumns(Map.of("hello", "world"));
+
+        assertEquals(renamed._columnName, "world");
+    }
+}