Repository: databricks/koalas

Issue:
Cannot operate on 2 different ks.DataFrames
Example:
```python
kdf = ks.DataFrame({"timestamp":[0.0, 0.5, 1.0, 0.0, 0.5],
              "car_id": ['A','A','A','B','B'], 
              "battery_charge": [100, 90, 80,100,90]
              })

grouped = kdf.groupby('car_id')

print(grouped.last() - grouped.first() )
```
results in:
```python
ValueError: sub with another DataFrame or a sequence is currently not supported; however, got databricks.koalas.frame.DataFrame.
```
I ran into that error multiple times with different operations. This, together with implementing the `DataFrame.apply()`  ( https://github.com/databricks/koalas/issues/554 ) and re-implementing the `groupby.apply()` ( https://github.com/databricks/koalas/issues/409 )  is my current biggest pain in using the package. 


Discussion:
We might have to allow an operation between DataFrames and have a .. environment variable to control it since that's expensive ..? Maybe let me give a shot and discuss there if we need that or not.
Yea that makes sense. Implement it. Hide it behind a flag and have it off by default. The error message can say set x to true to turn it on, but it could be computationally expensive due to an implicit join.


Resolving patch:
diff --git a/databricks/koalas/base.py b/databricks/koalas/base.py
--- a/databricks/koalas/base.py
+++ b/databricks/koalas/base.py
@@ -32,6 +32,7 @@
 from databricks import koalas as ks  # For running doctests and reference resolution in PyCharm.
 from databricks.koalas.internal import _InternalFrame
 from databricks.koalas.typedef import pandas_wraps
+from databricks.koalas.utils import align_diff_series
 
 
 def _column_op(f):
@@ -47,16 +48,23 @@ def _column_op(f):
     """
     @wraps(f)
     def wrapper(self, *args):
-        assert all((not isinstance(arg, IndexOpsMixin))
-                   or (arg._kdf is self._kdf) for arg in args), \
-            "Cannot combine column argument because it comes from a different dataframe"
-
         # It is possible for the function `f` takes other arguments than Spark Column.
         # To cover this case, explicitly check if the argument is Koalas Series and
         # extract Spark Column. For other arguments, they are used as are.
-        args = [arg._scol if isinstance(arg, IndexOpsMixin) else arg for arg in args]
-        scol = f(self._scol, *args)
-        return self._with_new_scol(scol)
+        cols = [arg for arg in args if isinstance(arg, IndexOpsMixin)]
+        if all(self._kdf is col._kdf for col in cols):
+            # Same DataFrame anchors
+            args = [arg._scol if isinstance(arg, IndexOpsMixin) else arg for arg in args]
+            scol = f(self._scol, *args)
+
+            return self._with_new_scol(scol)
+        else:
+            # Different DataFrame anchors
+            def apply_func(this_column, *that_columns):
+                return f(this_column, *that_columns)
+
+            return align_diff_series(apply_func, self, *args, how="full")
+
     return wrapper
 
 
diff --git a/databricks/koalas/frame.py b/databricks/koalas/frame.py
--- a/databricks/koalas/frame.py
+++ b/databricks/koalas/frame.py
@@ -17,12 +17,14 @@
 """
 A wrapper class for Spark DataFrame to behave similar to pandas DataFrame.
 """
+from collections import OrderedDict
 from distutils.version import LooseVersion
 import re
 import warnings
 import inspect
 from functools import partial, reduce
 import sys
+from itertools import zip_longest
 from typing import Any, Optional, List, Tuple, Union, Generic, TypeVar
 
 import numpy as np
@@ -42,7 +44,7 @@
 from pyspark.sql.functions import pandas_udf
 
 from databricks import koalas as ks  # For running doctests and reference resolution in PyCharm.
-from databricks.koalas.utils import validate_arguments_and_invoke_function
+from databricks.koalas.utils import validate_arguments_and_invoke_function, align_diff_frames
 from databricks.koalas.generic import _Frame, max_display_count
 from databricks.koalas.internal import _InternalFrame, IndexMap
 from databricks.koalas.missing.frame import _MissingPandasLikeDataFrame
@@ -386,19 +388,32 @@ def calculate_columns_axis(*cols):
 
     # Arithmetic Operators
     def _map_series_op(self, op, other):
-        if isinstance(other, DataFrame) or is_sequence(other):
+        if not isinstance(other, DataFrame) and is_sequence(other):
             raise ValueError(
-                "%s with another DataFrame or a sequence is currently not supported; "
+                "%s with a sequence is currently not supported; "
                 "however, got %s." % (op, type(other)))
 
         applied = []
-        for column in self._internal.data_columns:
-            applied.append(getattr(self[column], op)(other))
+        if isinstance(other, DataFrame) and self is not other:
+            # Different DataFrames
+            def apply_op(kdf, this_columns, that_columns):
+                for this_column, that_column in zip(this_columns, that_columns):
+                    yield getattr(kdf[this_column], op)(kdf[that_column])
+
+            return align_diff_frames(apply_op, self, other, fillna=True, how="full")
+        elif isinstance(other, DataFrame) and self is not other:
+            # Same DataFrames
+            for column in self._internal.data_columns:
+                applied.append(getattr(self[column], op)(other[column]))
+        else:
+            # DataFrame and Series
+            for column in self._internal.data_columns:
+                applied.append(getattr(self[column], op)(other))
 
-        sdf = self._sdf.select(
-            self._internal.index_scols + [c._scol for c in applied])
-        internal = self._internal.copy(sdf=sdf, data_columns=[c.name for c in applied])
-        return DataFrame(internal)
+            sdf = self._sdf.select(
+                self._internal.index_scols + [c._scol for c in applied])
+            internal = self._internal.copy(sdf=sdf, data_columns=[c.name for c in applied])
+            return DataFrame(internal)
 
     def __add__(self, other):
         return self._map_series_op("add", other)
@@ -6337,17 +6352,33 @@ def __getitem__(self, key):
 
     def __setitem__(self, key, value):
         from databricks.koalas.series import Series
-        # For now, we don't support realignment against different dataframes.
-        # This is too expensive in Spark.
-        # Are we assigning against a column?
-        if isinstance(value, Series):
-            assert value._kdf is self, \
-                "Cannot combine column argument because it comes from a different dataframe"
-        if isinstance(key, (tuple, list)):
-            assert isinstance(value.schema, StructType)
-            field_names = value.schema.fieldNames()
+
+        if (isinstance(value, Series) and value._kdf is not self) or \
+                (isinstance(value, DataFrame) and value is not self):
+            # Different Series or DataFrames
+            if isinstance(value, Series):
+                value = value.to_frame()
+
+            if not isinstance(key, (tuple, list)):
+                key = [key]
+
+            def assign_columns(kdf, this_columns, that_columns):
+                assert len(key) == len(that_columns)
+                # Note that here intentionally uses `zip_longest` that combine
+                # that_columns.
+                for k, this_column, that_column in zip_longest(key, this_columns, that_columns):
+                    yield kdf[that_column].rename(k)
+                    if this_column != k and this_column is not None:
+                        yield kdf[this_column]
+
+            kdf = align_diff_frames(assign_columns, self, value, fillna=False, how="left")
+        elif isinstance(key, (tuple, list)):
+            assert isinstance(value, DataFrame)
+            # Same DataFrames.
+            field_names = value.columns
             kdf = self.assign(**{k: value[c] for k, c in zip(key, field_names)})
         else:
+            # Same Series.
             kdf = self.assign(**{key: value})
 
         self._internal = kdf._internal
diff --git a/databricks/koalas/utils.py b/databricks/koalas/utils.py
--- a/databricks/koalas/utils.py
+++ b/databricks/koalas/utils.py
@@ -18,11 +18,254 @@
 """
 
 import functools
+import os
+from collections import OrderedDict
 from typing import Callable, Dict, Union
 
 from pyspark import sql as spark
+from pyspark.sql import functions as F
+from pyspark.sql.types import FloatType
 import pandas as pd
 
+from databricks import koalas as ks  # For running doctests and reference resolution in PyCharm.
+
+
+def combine_frames(this, *args, how="full"):
+    """
+    This method combines `this` DataFrame with a different `that` DataFrame or
+    Series from a different DataFrame.
+
+    It returns a DataFrame that has prefix `this_` and `that_` to distinct
+    the columns names from both DataFrames
+
+    It internally performs a join operation which can be expensive in general.
+    So, if `OPS_ON_DIFF_FRAMES` environment variable is not set,
+    this method throws an exception.
+    """
+    from databricks.koalas import Series
+    from databricks.koalas import DataFrame
+
+    if all(isinstance(arg, Series) for arg in args):
+        assert all(arg._kdf is args[0]._kdf for arg in args), \
+            "Currently only one different DataFrame (from given Series) is supported"
+        if this is args[0]._kdf:
+            return  # We don't need to combine. All series is in this.
+        that = args[0]._kdf[[ser.name for ser in args]]
+    elif len(args) == 1 and isinstance(args[0], DataFrame):
+        assert isinstance(args[0], DataFrame)
+        if this is args[0]:
+            return  # We don't need to combine. `this` and `that` are same.
+        that = args[0]
+    else:
+        raise AssertionError("args should be single DataFrame or "
+                             "single/multiple Series")
+
+    if os.environ.get("OPS_ON_DIFF_FRAMES", "false").lower() == "true":
+        this_index_map = this._internal.index_map
+        this_data_columns = this._internal.data_columns
+        that_index_map = that._internal.index_map
+        that_data_columns = that._internal.data_columns
+        assert len(this_index_map) == len(that_index_map)
+
+        join_scols = []
+        merged_index_scols = []
+
+        # If the same named index is found, that's used.
+        for this_column, this_name in this_index_map:
+            for that_col, that_name in that_index_map:
+                if this_name == that_name:
+                    # We should merge the Spark columns into one
+                    # to mimic pandas' behavior.
+                    this_scol = this._internal.scol_for(this_column)
+                    that_scol = that._internal.scol_for(that_col)
+                    join_scol = this_scol == that_scol
+                    join_scols.append(join_scol)
+                    merged_index_scols.append(
+                        F.when(
+                            this_scol.isNotNull(), this_scol
+                        ).otherwise(that_scol).alias(this_column))
+                    break
+            else:
+                raise ValueError("Index names must be exactly matched currently.")
+
+        assert len(join_scols) > 0, "cannot join with no overlapping index names"
+
+        index_columns = this._internal.index_columns
+        joined_df = this._sdf.alias("this").join(
+            that._sdf.alias("that"), on=join_scols, how=how)
+
+        joined_df = joined_df.select(
+            merged_index_scols +
+            [this[c]._scol.alias("__this_%s" % this[c].name) for c in this_data_columns] +
+            [that[c]._scol.alias("__that_%s" % that[c].name) for c in that_data_columns])
+
+        new_data_columns = [c for c in joined_df.columns if c not in index_columns]
+        return DataFrame(
+            this._internal.copy(sdf=joined_df, data_columns=new_data_columns))
+    else:
+        raise ValueError("Cannot combine column argument because "
+                         "it comes from a different dataframe")
+
+
+def align_diff_frames(resolve_func, this, that, fillna=True, how="full"):
+    """
+    This method aligns two different DataFrames with a given `func`. Columns are resolved and
+    handled within the given `func`.
+    To use this, `OPS_ON_DIFF_FRAMES` environment variable should be enabled, for now.
+
+    :param resolve_func: Takes aligned (joined) DataFrame, the column of the current DataFrame, and
+        the column of another DataFrame. It returns an iterable that produces Series.
+
+        >>> import os
+        >>>
+        >>> prev = os.environ.get("OPS_ON_DIFF_FRAMES", "false")
+        >>> os.environ["OPS_ON_DIFF_FRAMES"] = "true"
+        >>>
+        >>> kdf1 = ks.DataFrame({'a': [9, 8, 7, 6, 5, 4, 3, 2, 1]})
+        >>> kdf2 = ks.DataFrame({'a': [9, 8, 7, 6, 5, 4, 3, 2, 1]})
+        >>>
+        >>> def func(kdf, this_columns, that_columns):
+        ...    kdf  # conceptually this is A + B.
+        ...
+        ...    # Within this function, Series from A or B can be performed against `kdf`.
+        ...    this_column = this_columns[0]  # this is 'a' from kdf1.
+        ...    that_column = that_columns[0]  # this is 'a' from kdf2.
+        ...    new_series = kdf[this_column] - kdf[that_column]
+        ...
+        ...    # This new series will be placed in new DataFrame.
+        ...    yield new_series.rename(this_column)  # or list(new_series)
+        >>>
+        >>>
+        >>> align_diff_frames(func, kdf1, kdf2).sort_index()
+           a
+        0  0
+        1  0
+        2  0
+        3  0
+        4  0
+        5  0
+        6  0
+        7  0
+        8  0
+        >>> os.environ["OPS_ON_DIFF_FRAMES"] = prev
+
+    :param this: a DataFrame to align
+    :param that: another DataFrame to align
+    :param fillna: If True, it fills missing values in non-common columns in both `this` and `that`.
+        Otherwise, it returns as are.
+    :param how: join way. In addition, it affects how `resolve_func` resolves the column conflict.
+        - full: `resolve_func` should resolve only common columns from 'this' and 'that' DataFrames.
+            For instance, if 'this' has columns A, B, C and that has B, C, D, `this_columns` and
+            'that_columns' in this function are B, C and B, C.
+        - left: `resolve_func` should resolve columns including that columns.
+            For instance, if 'this' has columns A, B, C and that has B, C, D, `this_columns` is
+            B, C but `that_columns` are B, C, D.
+    :return: Alined DataFrame
+    """
+    from databricks.koalas import DataFrame
+
+    assert how == "full" or how == "left"
+
+    this_data_columns = this._internal.data_columns
+    that_data_columns = that._internal.data_columns
+    common_columns = set(this_data_columns).intersection(that_data_columns)
+
+    # 1. Full outer join given two dataframes.
+    combined = combine_frames(this, that, how=how)
+
+    # 2. Apply given function to transform the columns in a batch and keep the new columns.
+    combined_data_columns = combined._internal.data_columns
+
+    that_columns_to_apply = []
+    this_columns_to_apply = []
+    additional_that_columns = []
+    columns_to_keep = []
+
+    for combined_column in combined_data_columns:
+        for common_column in common_columns:
+            if combined_column == "__this_%s" % common_column:
+                this_columns_to_apply.append(combined_column)
+                break
+            elif combined_column == "__that_%s" % common_column:
+                that_columns_to_apply.append(combined_column)
+                break
+        else:
+            if how == "left" and \
+                    combined_column in ["__that_%s" % c for c in that_data_columns]:
+                # In this case, we will drop `that_columns` in `columns_to_keep` but passes
+                # it later to `func`. `func` should resolve it.
+                # Note that adding this into a separate list (`additional_that_columns`)
+                # is intentional so that `this_columns` and `that_columns` can be paired.
+                additional_that_columns.append(combined_column)
+            elif fillna:
+                columns_to_keep.append(F.lit(None).cast(FloatType()).alias(combined_column))
+            else:
+                columns_to_keep.append(F.col(combined_column))
+
+    that_columns_to_apply += additional_that_columns
+
+    # Should extract columns to apply and do it in a batch in case
+    # it adds new columns for example.
+    kser_set = list(resolve_func(combined, this_columns_to_apply, that_columns_to_apply))
+    columns_applied = [c._scol for c in kser_set]
+
+    sdf = combined._sdf.select(
+        combined._internal.index_scols + columns_applied + columns_to_keep)
+
+    # 3. Restore the names back and deduplicate columns.
+    this_columns = OrderedDict()
+    # Add columns in an order of its original frame.
+    new_data_columns = [c for c in sdf.columns if c not in combined._internal.index_columns]
+    for this_data_column in this_data_columns:
+        for new_column in new_data_columns:
+            striped = new_column
+            if new_column.startswith("__this_") or new_column.startswith("__that_"):
+                striped = new_column[7:]  # cut out the prefix (either __this_ or __that_).
+
+            # Respect the applied columns first if there are duplicated columns found.
+            if striped not in this_columns and this_data_column == striped:
+                this_columns[striped] = F.col(new_column).alias(striped)
+                break
+
+    # After that, we will add the rest columns.
+    other_columns = OrderedDict()
+    for new_column in new_data_columns:
+        striped = new_column
+        if new_column.startswith("__this_") or new_column.startswith("__that_"):
+            striped = new_column[7:]  # cut out the prefix (either __this_ or __that_).
+
+        # Respect the applied columns first if there are duplicated columns found.
+        if striped not in this_columns:
+            other_columns[striped] = F.col(new_column).alias(striped)
+
+    sdf = sdf.select(
+        combined._internal.index_scols +
+        list(this_columns.values()) +
+        list(other_columns.values()))
+
+    new_data_columns = [c for c in sdf.columns if c not in combined._internal.index_columns]
+    internal = combined._internal.copy(sdf=sdf, data_columns=new_data_columns)
+    return DataFrame(internal)
+
+
+def align_diff_series(func, this, *args, how="full"):
+    from databricks.koalas.base import IndexOpsMixin
+
+    cols = [arg for arg in args if isinstance(arg, IndexOpsMixin)]
+    this_series = this
+    combined = combine_frames(this._kdf, *cols, how=how)
+
+    that_columns = [F.col("__that_%s" % arg.name)
+                    if isinstance(arg, IndexOpsMixin) else arg for arg in args]
+
+    scol = func(F.col("__this_%s" % this_series.name), *that_columns).alias(this_series.name)
+
+    this_series._kdf = combined
+    series = this_series._with_new_scol(scol)
+
+    # To make the anchor frame to only have the new Series. This loses the original anchor.
+    return series.to_frame()[this_series.name]
+
 
 def default_session(conf=None):
     if conf is None:


Test patch:
diff --git a/databricks/koalas/tests/test_dataframe.py b/databricks/koalas/tests/test_dataframe.py
--- a/databricks/koalas/tests/test_dataframe.py
+++ b/databricks/koalas/tests/test_dataframe.py
@@ -868,12 +868,12 @@ def test_clip(self):
     def test_binary_operators(self):
         self.assertRaisesRegex(
             ValueError,
-            'with another DataFrame or a sequence is currently not supported',
+            'it comes from a different dataframe',
             lambda: ks.range(10).add(ks.range(10)))
 
         self.assertRaisesRegex(
             ValueError,
-            'with another DataFrame or a sequence is currently not supported',
+            'add with a sequence is currently not supported',
             lambda: ks.range(10).add(ks.range(10).id))
 
     def test_sample(self):
diff --git a/databricks/koalas/tests/test_ops_on_diff_frames.py b/databricks/koalas/tests/test_ops_on_diff_frames.py
new file mode 100644
--- /dev/null
+++ b/databricks/koalas/tests/test_ops_on_diff_frames.py
@@ -0,0 +1,345 @@
+#
+# Copyright (C) 2019 Databricks, Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+import os
+
+import pandas as pd
+
+from databricks import koalas as ks
+from databricks.koalas.testing.utils import ReusedSQLTestCase, SQLTestUtils
+
+
+class OpsOnDiffFramesEnabledTest(ReusedSQLTestCase, SQLTestUtils):
+
+    @classmethod
+    def setUpClass(cls):
+        super(OpsOnDiffFramesEnabledTest, cls).setUpClass()
+        cls.should_ops_on_diff_frames = os.environ.get('OPS_ON_DIFF_FRAMES', 'false')
+        os.environ['OPS_ON_DIFF_FRAMES'] = 'true'
+
+    @classmethod
+    def tearDownClass(cls):
+        super(OpsOnDiffFramesEnabledTest, cls).tearDownClass()
+        os.environ['OPS_ON_DIFF_FRAMES'] = cls.should_ops_on_diff_frames
+
+    @property
+    def pdf1(self):
+        return pd.DataFrame({
+            'a': [1, 2, 3, 4, 5, 6, 7, 8, 9],
+            'b': [4, 5, 6, 3, 2, 1, 0, 0, 0],
+        }, index=[0, 1, 3, 5, 6, 8, 9, 10, 11])
+
+    @property
+    def pdf2(self):
+        return pd.DataFrame({
+            'a': [9, 8, 7, 6, 5, 4, 3, 2, 1],
+            'b': [0, 0, 0, 4, 5, 6, 1, 2, 3],
+        }, index=list(range(9)))
+
+    @property
+    def pdf3(self):
+        return pd.DataFrame({
+            'b': [1, 1, 1, 1, 1, 1, 1, 1, 1],
+            'c': [1, 1, 1, 1, 1, 1, 1, 1, 1],
+        }, index=list(range(9)))
+
+    @property
+    def pdf4(self):
+        return pd.DataFrame({
+            'e': [2, 2, 2, 2, 2, 2, 2, 2, 2],
+            'f': [2, 2, 2, 2, 2, 2, 2, 2, 2],
+        }, index=list(range(9)))
+
+    @property
+    def pdf5(self):
+        return pd.DataFrame({
+            'a': [1, 2, 3, 4, 5, 6, 7, 8, 9],
+            'b': [4, 5, 6, 3, 2, 1, 0, 0, 0],
+            'c': [4, 5, 6, 3, 2, 1, 0, 0, 0],
+        }, index=[0, 1, 3, 5, 6, 8, 9, 10, 11]).set_index(['a', 'b'])
+
+    @property
+    def pdf6(self):
+        return pd.DataFrame({
+            'a': [9, 8, 7, 6, 5, 4, 3, 2, 1],
+            'b': [0, 0, 0, 4, 5, 6, 1, 2, 3],
+            'c': [9, 8, 7, 6, 5, 4, 3, 2, 1],
+            'e': [4, 5, 6, 3, 2, 1, 0, 0, 0],
+        }, index=list(range(9))).set_index(['a', 'b'])
+
+    @property
+    def kdf1(self):
+        return ks.from_pandas(self.pdf1)
+
+    @property
+    def kdf2(self):
+        return ks.from_pandas(self.pdf2)
+
+    @property
+    def kdf3(self):
+        return ks.from_pandas(self.pdf3)
+
+    @property
+    def kdf4(self):
+        return ks.from_pandas(self.pdf4)
+
+    @property
+    def kdf5(self):
+        return ks.from_pandas(self.pdf5)
+
+    @property
+    def kdf6(self):
+        return ks.from_pandas(self.pdf6)
+
+    def test_no_index(self):
+        with self.assertRaisesRegex(AssertionError, "cannot join with no overlapping index name"):
+            ks.range(10) + ks.range(10)
+
+    def test_no_matched_index(self):
+        with self.assertRaisesRegex(ValueError, "Index names must be exactly matched"):
+            ks.DataFrame({'a': [1, 2, 3]}).set_index('a') + \
+                ks.DataFrame({'b': [1, 2, 3]}).set_index('b')
+
+    def test_arithmetic(self):
+        # Series
+        self.assertEqual(
+            repr((self.kdf1.a - self.kdf2.b).sort_index()),
+            repr((self.pdf1.a - self.pdf2.b).rename("a").sort_index()))
+
+        self.assertEqual(
+            repr((self.kdf1.a * self.kdf2.a).sort_index()),
+            repr((self.pdf1.a * self.pdf2.a).rename("a").sort_index()))
+
+        self.assertEqual(
+            repr((self.kdf1["a"] / self.kdf2["a"]).sort_index()),
+            repr((self.pdf1["a"] / self.pdf2["a"]).rename("a").sort_index()))
+
+        # DataFrame
+        self.assertEqual(
+            repr((self.kdf1 + self.kdf2).sort_index()),
+            repr((self.pdf1 + self.pdf2).sort_index()))
+
+    def test_arithmetic_chain(self):
+        # Series
+        self.assertEqual(
+            repr((self.kdf1.a - self.kdf2.b - self.kdf3.c).sort_index()),
+            repr((self.pdf1.a - self.pdf2.b - self.pdf3.c).rename("a").sort_index()))
+
+        self.assertEqual(
+            repr((self.kdf1.a * self.kdf2.a * self.kdf3.c).sort_index()),
+            repr((self.pdf1.a * self.pdf2.a * self.pdf3.c).rename("a").sort_index()))
+
+        self.assertEqual(
+            repr((self.kdf1["a"] / self.kdf2["a"] / self.kdf3["c"]).sort_index()),
+            repr((self.pdf1["a"] / self.pdf2["a"] / self.pdf3["c"]).rename("a").sort_index()))
+
+        # DataFrame
+        self.assertEqual(
+            repr((self.kdf1 + self.kdf2 - self.kdf3).sort_index()),
+            repr((self.pdf1 + self.pdf2 - self.pdf3).sort_index()))
+
+    def test_different_columns(self):
+        self.assertEqual(
+            repr((self.kdf1 + self.kdf4).sort_index()),
+            repr((self.pdf1 + self.pdf4).sort_index()))
+
+    def test_assignment_series(self):
+        kdf = ks.from_pandas(self.pdf1)
+        pdf = self.pdf1.copy()
+        kdf['a'] = self.kdf2.a
+        pdf['a'] = self.pdf2.a
+
+        self.assert_eq(kdf.sort_index(), pdf.sort_index())
+
+        kdf = ks.from_pandas(self.pdf1)
+        pdf = self.pdf1.copy()
+        kdf['a'] = self.kdf2.b
+        pdf['a'] = self.pdf2.b
+
+        self.assert_eq(kdf.sort_index(), pdf.sort_index())
+
+        kdf = ks.from_pandas(self.pdf1)
+        pdf = self.pdf1.copy()
+        kdf['c'] = self.kdf2.a
+
+        pdf['c'] = self.pdf2.a
+
+        self.assert_eq(kdf.sort_index(), pdf.sort_index())
+
+    def test_assignment_frame(self):
+        kdf = ks.from_pandas(self.pdf1)
+        pdf = self.pdf1.copy()
+        kdf[['a', 'b']] = self.kdf1
+        pdf[['a', 'b']] = self.pdf1
+
+        self.assert_eq(kdf.sort_index(), pdf.sort_index())
+
+        # 'c' does not exist in `kdf`.
+        kdf = ks.from_pandas(self.pdf1)
+        pdf = self.pdf1.copy()
+        kdf[['b', 'c']] = self.kdf1
+        pdf[['b', 'c']] = self.pdf1
+
+        self.assert_eq(kdf.sort_index(), pdf.sort_index())
+
+        # 'c' and 'd' do not exist in `kdf`.
+        kdf = ks.from_pandas(self.pdf1)
+        pdf = self.pdf1.copy()
+        kdf[['c', 'd']] = self.kdf1
+        pdf[['c', 'd']] = self.pdf1
+
+        self.assert_eq(kdf.sort_index(), pdf.sort_index())
+
+    def test_assignment_series_chain(self):
+        kdf = ks.from_pandas(self.pdf1)
+        pdf = self.pdf1.copy()
+        kdf['a'] = self.kdf1.a
+        pdf['a'] = self.pdf1.a
+
+        kdf['a'] = self.kdf2.b
+        pdf['a'] = self.pdf2.b
+
+        kdf['d'] = self.kdf3.c
+        pdf['d'] = self.pdf3.c
+
+        self.assert_eq(kdf.sort_index(), pdf.sort_index())
+
+    def test_assignment_frame_chain(self):
+        kdf = ks.from_pandas(self.pdf1)
+        pdf = self.pdf1.copy()
+        kdf[['a', 'b']] = self.kdf1
+        pdf[['a', 'b']] = self.pdf1
+
+        kdf[['e', 'f']] = self.kdf3
+        pdf[['e', 'f']] = self.pdf3
+
+        kdf[['b', 'c']] = self.kdf2
+        pdf[['b', 'c']] = self.pdf2
+
+        self.assert_eq(kdf.sort_index(), pdf.sort_index())
+
+    def test_multi_index_arithmetic(self):
+        # Series
+        self.assertEqual(
+            repr((self.kdf5.c - self.kdf6.e).sort_index()),
+            repr((self.pdf5.c - self.pdf6.e).rename("c").sort_index()))
+
+        self.assertEqual(
+            repr((self.kdf5["c"] / self.kdf6["e"]).sort_index()),
+            repr((self.pdf5["c"] / self.pdf6["e"]).rename("c").sort_index()))
+
+        # DataFrame
+        self.assertEqual(
+            repr((self.kdf5 + self.kdf6).sort_index()),
+            repr((self.pdf5 + self.pdf6).sort_index()))
+
+    def test_multi_index_assignment_series(self):
+        kdf = ks.from_pandas(self.pdf5)
+        pdf = self.pdf5.copy()
+        kdf['x'] = self.kdf6.e
+        pdf['x'] = self.pdf6.e
+
+        self.assert_eq(kdf.sort_index(), pdf.sort_index())
+
+        kdf = ks.from_pandas(self.pdf5)
+        pdf = self.pdf5.copy()
+        kdf['x'] = self.kdf6.e
+        pdf['x'] = self.pdf6.e
+
+        self.assert_eq(kdf.sort_index(), pdf.sort_index())
+
+        kdf = ks.from_pandas(self.pdf5)
+        pdf = self.pdf5.copy()
+        kdf['c'] = self.kdf6.e
+
+        pdf['c'] = self.pdf6.e
+
+        self.assert_eq(kdf.sort_index(), pdf.sort_index())
+
+    def test_multi_index_assignment_frame(self):
+        kdf = ks.from_pandas(self.pdf5)
+        pdf = self.pdf5.copy()
+        kdf[['c']] = self.kdf5
+        pdf[['c']] = self.pdf5
+
+        self.assert_eq(kdf.sort_index(), pdf.sort_index())
+
+        kdf = ks.from_pandas(self.pdf5)
+        pdf = self.pdf5.copy()
+        kdf[['x']] = self.kdf5
+        pdf[['x']] = self.pdf5
+
+        self.assert_eq(kdf.sort_index(), pdf.sort_index())
+
+        kdf = ks.from_pandas(self.pdf6)
+        pdf = self.pdf6.copy()
+        kdf[['x', 'y']] = self.kdf6
+        pdf[['x', 'y']] = self.pdf6
+
+        self.assert_eq(kdf.sort_index(), pdf.sort_index())
+
+
+class OpsOnDiffFramesDisabledTest(ReusedSQLTestCase, SQLTestUtils):
+
+    @classmethod
+    def setUpClass(cls):
+        super(OpsOnDiffFramesDisabledTest, cls).setUpClass()
+        cls.should_ops_on_diff_frames = os.environ.get('OPS_ON_DIFF_FRAMES', 'false')
+        os.environ['OPS_ON_DIFF_FRAMES'] = 'false'
+
+    @classmethod
+    def tearDownClass(cls):
+        super(OpsOnDiffFramesDisabledTest, cls).tearDownClass()
+        os.environ['OPS_ON_DIFF_FRAMES'] = cls.should_ops_on_diff_frames
+
+    @property
+    def pdf1(self):
+        return pd.DataFrame({
+            'a': [1, 2, 3, 4, 5, 6, 7, 8, 9],
+            'b': [4, 5, 6, 3, 2, 1, 0, 0, 0],
+        }, index=[0, 1, 3, 5, 6, 8, 9, 9, 9])
+
+    @property
+    def pdf2(self):
+        return pd.DataFrame({
+            'a': [9, 8, 7, 6, 5, 4, 3, 2, 1],
+            'b': [0, 0, 0, 4, 5, 6, 1, 2, 3],
+        }, index=list(range(9)))
+
+    @property
+    def kdf1(self):
+        return ks.from_pandas(self.pdf1)
+
+    @property
+    def kdf2(self):
+        return ks.from_pandas(self.pdf2)
+
+    def test_arithmetic(self):
+        with self.assertRaisesRegex(ValueError, "Cannot combine column argument"):
+            self.kdf1.a - self.kdf2.b
+
+        with self.assertRaisesRegex(ValueError, "Cannot combine column argument"):
+            self.kdf1.a - self.kdf2.a
+
+        with self.assertRaisesRegex(ValueError, "Cannot combine column argument"):
+            self.kdf1["a"] - self.kdf2["a"]
+
+        with self.assertRaisesRegex(ValueError, "Cannot combine column argument"):
+            self.kdf1 - self.kdf2
+
+    def test_assignment(self):
+        with self.assertRaisesRegex(ValueError, "Cannot combine column argument"):
+            kdf = ks.from_pandas(self.pdf1)
+            kdf['c'] = self.kdf1.a