feat: Add Concatenate and Merge operations to DataFrame Operations (#11601)

* add concatenate and merge operations on tables

* [autofix.ci] apply automated fixes

* [autofix.ci] apply automated fixes (attempt 2/3)

* add code rabbit suggestions

* [autofix.ci] apply automated fixes

* [autofix.ci] apply automated fixes

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
Cristhian Zanforlin Lousa
2026-03-03 08:49:53 -03:00
committed by GitHub
parent 15b98537d7
commit 0cedf8a5a0
4 changed files with 418 additions and 19 deletions

View File

@ -222,14 +222,12 @@ class TestEdgeCases:
assert list(result.columns) == list(sample_dataframe.columns)
def test_invalid_operation_format(self, component, sample_dataframe):
"""Test with invalid operation format."""
"""Test with invalid operation format raises error."""
component.df = sample_dataframe
component.operation = "Invalid String" # Not list format
result = component.perform_operation()
# Should return original DataFrame
assert len(result) == len(sample_dataframe)
with pytest.raises(ValueError, match="Unsupported operation"):
component.perform_operation()
def test_empty_dataframe(self, component):
"""Test operations on empty DataFrame."""
@ -280,6 +278,8 @@ class TestDynamicUI:
"num_rows": {"show": False},
"replace_value": {"show": False},
"replacement_value": {"show": False},
"merge_on_column": {"show": False},
"merge_how": {"show": False},
}
# Select Filter operation
@ -305,6 +305,8 @@ class TestDynamicUI:
"num_rows": {"show": False},
"replace_value": {"show": False},
"replacement_value": {"show": False},
"merge_on_column": {"show": False},
"merge_how": {"show": False},
}
# Select Sort operation
@ -330,6 +332,8 @@ class TestDynamicUI:
"num_rows": {"show": True},
"replace_value": {"show": True},
"replacement_value": {"show": True},
"merge_on_column": {"show": True},
"merge_how": {"show": True},
}
# Deselect operation (empty list)
@ -350,6 +354,8 @@ class TestDynamicUI:
assert updated_config["num_rows"]["show"] is False
assert updated_config["replace_value"]["show"] is False
assert updated_config["replacement_value"]["show"] is False
assert updated_config["merge_on_column"]["show"] is False
assert updated_config["merge_how"]["show"] is False
class TestDataTypes:
@ -385,6 +391,241 @@ class TestDataTypes:
assert len(result) == 2 # "text" and "more_text"
class TestConcatenateOperation:
"""Test concatenate operation for combining multiple DataFrames."""
def test_concatenate_two_dataframes(self, component):
"""Test concatenating two DataFrames vertically."""
df1 = DataFrame(pd.DataFrame({"id": [1, 2], "name": ["Alice", "Bob"]}))
df2 = DataFrame(pd.DataFrame({"id": [3, 4], "name": ["Charlie", "Diana"]}))
component.df = [df1, df2]
component.operation = [{"name": "Concatenate", "icon": "combine"}]
result = component.perform_operation()
assert len(result) == 4
assert list(result["id"]) == [1, 2, 3, 4]
assert list(result["name"]) == ["Alice", "Bob", "Charlie", "Diana"]
def test_concatenate_single_dataframe(self, component):
"""Test concatenate with only one DataFrame returns it unchanged."""
df1 = DataFrame(pd.DataFrame({"id": [1, 2], "name": ["Alice", "Bob"]}))
component.df = [df1]
component.operation = [{"name": "Concatenate", "icon": "combine"}]
result = component.perform_operation()
assert len(result) == 2
assert list(result["id"]) == [1, 2]
def test_concatenate_different_row_counts(self, component):
"""Test concatenating DataFrames with different row counts."""
df1 = DataFrame(pd.DataFrame({"id": [1, 2, 3], "value": ["a", "b", "c"]}))
df2 = DataFrame(pd.DataFrame({"id": [4, 5], "value": ["d", "e"]}))
component.df = [df1, df2]
component.operation = [{"name": "Concatenate", "icon": "combine"}]
result = component.perform_operation()
assert len(result) == 5
class TestMergeOperation:
"""Test merge operation for joining DataFrames."""
def test_merge_inner_join(self, component):
"""Test inner merge on common column."""
df1 = DataFrame(pd.DataFrame({"id": [1, 2, 3], "name": ["Alice", "Bob", "Charlie"]}))
df2 = DataFrame(pd.DataFrame({"id": [2, 3, 4], "city": ["NYC", "LA", "Chicago"]}))
component.df = [df1, df2]
component.operation = [{"name": "Merge", "icon": "merge"}]
component.merge_on_column = "id"
component.merge_how = "inner"
result = component.perform_operation()
assert len(result) == 2 # Only ids 2 and 3 exist in both
assert "name" in result.columns
assert "city" in result.columns
def test_merge_outer_join(self, component):
"""Test outer merge includes all records."""
df1 = DataFrame(pd.DataFrame({"id": [1, 2], "name": ["Alice", "Bob"]}))
df2 = DataFrame(pd.DataFrame({"id": [2, 3], "city": ["NYC", "LA"]}))
component.df = [df1, df2]
component.operation = [{"name": "Merge", "icon": "merge"}]
component.merge_on_column = "id"
component.merge_how = "outer"
result = component.perform_operation()
assert len(result) == 3 # ids 1, 2, 3
def test_merge_left_join(self, component):
"""Test left merge keeps all records from first DataFrame."""
df1 = DataFrame(pd.DataFrame({"id": [1, 2, 3], "name": ["Alice", "Bob", "Charlie"]}))
df2 = DataFrame(pd.DataFrame({"id": [2, 4], "city": ["NYC", "Chicago"]}))
component.df = [df1, df2]
component.operation = [{"name": "Merge", "icon": "merge"}]
component.merge_on_column = "id"
component.merge_how = "left"
result = component.perform_operation()
assert len(result) == 3 # All from df1
def test_merge_right_join(self, component):
"""Test right merge keeps all records from second DataFrame."""
df1 = DataFrame(pd.DataFrame({"id": [1, 2], "name": ["Alice", "Bob"]}))
df2 = DataFrame(pd.DataFrame({"id": [2, 3, 4], "city": ["NYC", "LA", "Chicago"]}))
component.df = [df1, df2]
component.operation = [{"name": "Merge", "icon": "merge"}]
component.merge_on_column = "id"
component.merge_how = "right"
result = component.perform_operation()
assert len(result) == 3 # All from df2
def test_merge_single_dataframe_returns_original(self, component):
"""Test merge with single DataFrame returns it unchanged."""
df1 = DataFrame(pd.DataFrame({"id": [1, 2], "name": ["Alice", "Bob"]}))
component.df = [df1]
component.operation = [{"name": "Merge", "icon": "merge"}]
component.merge_on_column = "id"
component.merge_how = "inner"
result = component.perform_operation()
assert len(result) == 2
def test_merge_invalid_column_raises_error(self, component):
"""Test merge with non-existent column raises ValueError."""
df1 = DataFrame(pd.DataFrame({"id": [1, 2], "name": ["Alice", "Bob"]}))
df2 = DataFrame(pd.DataFrame({"id": [2, 3], "city": ["NYC", "LA"]}))
component.df = [df1, df2]
component.operation = [{"name": "Merge", "icon": "merge"}]
component.merge_on_column = "non_existent"
component.merge_how = "inner"
with pytest.raises(ValueError, match="not found in first DataFrame"):
component.perform_operation()
def test_merge_same_columns_coalesces_values(self, component):
"""Test merge with same columns uses coalesce (df1 value or df2 value)."""
df1 = DataFrame(pd.DataFrame({"id": [1, 2], "value": ["a", "b"]}))
df2 = DataFrame(pd.DataFrame({"id": [2, 3], "value": ["x", "y"]}))
component.df = [df1, df2]
component.operation = [{"name": "Merge", "icon": "merge"}]
component.merge_on_column = "id"
component.merge_how = "outer"
result = component.perform_operation()
assert len(result) == 3
# Check no duplicate columns with _df2 suffix
assert "value_df2" not in result.columns
# Verify coalesced values
assert result.loc[result["id"] == 1, "value"].iloc[0] == "a" # from df1
assert result.loc[result["id"] == 2, "value"].iloc[0] == "b" # from df1 (coalesced)
assert result.loc[result["id"] == 3, "value"].iloc[0] == "y" # from df2
def test_merge_more_than_two_dataframes_raises_error(self, component):
"""Test merge with more than 2 DataFrames raises ValueError."""
df1 = DataFrame(pd.DataFrame({"id": [1], "name": ["A"]}))
df2 = DataFrame(pd.DataFrame({"id": [2], "name": ["B"]}))
df3 = DataFrame(pd.DataFrame({"id": [3], "name": ["C"]}))
component.df = [df1, df2, df3]
component.operation = [{"name": "Merge", "icon": "merge"}]
component.merge_on_column = "id"
component.merge_how = "inner"
with pytest.raises(ValueError, match="Merge requires exactly"):
component.perform_operation()
class TestListInputHandling:
"""Test that component handles list inputs correctly."""
def test_operations_use_first_dataframe_from_list(self, component):
"""Test that non-merge operations use only the first DataFrame."""
df1 = DataFrame(pd.DataFrame({"id": [1, 2], "name": ["Alice", "Bob"]}))
df2 = DataFrame(pd.DataFrame({"id": [3, 4], "name": ["Charlie", "Diana"]}))
component.df = [df1, df2]
component.operation = [{"name": "Head", "icon": "arrow-up"}]
component.num_rows = 1
result = component.perform_operation()
assert len(result) == 1
assert result.iloc[0]["name"] == "Alice" # From first DataFrame
class TestMergeDynamicUI:
"""Test dynamic UI for Merge and Concatenate operations."""
def test_merge_fields_show(self, component):
"""Test that merge fields show when Merge is selected."""
build_config = {
"column_name": {"show": False},
"filter_value": {"show": False},
"filter_operator": {"show": False},
"ascending": {"show": False},
"new_column_name": {"show": False},
"new_column_value": {"show": False},
"columns_to_select": {"show": False},
"num_rows": {"show": False},
"replace_value": {"show": False},
"replacement_value": {"show": False},
"merge_on_column": {"show": False},
"merge_how": {"show": False},
}
updated_config = component.update_build_config(build_config, [{"name": "Merge", "icon": "merge"}], "operation")
assert updated_config["merge_on_column"]["show"] is True
assert updated_config["merge_how"]["show"] is True
assert updated_config["column_name"]["show"] is False
def test_concatenate_hides_all_extra_fields(self, component):
"""Test that Concatenate operation hides all extra fields."""
build_config = {
"column_name": {"show": True},
"filter_value": {"show": True},
"filter_operator": {"show": True},
"ascending": {"show": True},
"new_column_name": {"show": True},
"new_column_value": {"show": True},
"columns_to_select": {"show": True},
"num_rows": {"show": True},
"replace_value": {"show": True},
"replacement_value": {"show": True},
"merge_on_column": {"show": True},
"merge_how": {"show": True},
}
updated_config = component.update_build_config(
build_config, [{"name": "Concatenate", "icon": "combine"}], "operation"
)
# Concatenate doesn't need any extra fields
assert updated_config["column_name"]["show"] is False
assert updated_config["merge_on_column"]["show"] is False
assert updated_config["merge_how"]["show"] is False
# Integration test to verify all operators work together
def test_all_filter_operators_comprehensive():
"""Comprehensive test of all filter operators on the same dataset."""

File diff suppressed because one or more lines are too long

View File

@ -1381,7 +1381,7 @@
},
"DataFrameOperations": {
"versions": {
"0.3.0": "904f4eaebccd"
"0.3.0": "e2b4323d4ed5"
}
},
"DynamicCreateData": {

View File

@ -16,9 +16,11 @@ class DataFrameOperationsComponent(Component):
OPERATION_CHOICES = [
"Add Column",
"Concatenate",
"Drop Column",
"Filter",
"Head",
"Merge",
"Rename Column",
"Replace Value",
"Select Columns",
@ -31,8 +33,9 @@ class DataFrameOperationsComponent(Component):
DataFrameInput(
name="df",
display_name="DataFrame",
info="The input DataFrame to operate on.",
info="The input DataFrame to operate on. Connect multiple DataFrames for merge or concatenate operations.",
required=True,
is_list=True,
),
SortableListInput(
name="operation",
@ -41,9 +44,11 @@ class DataFrameOperationsComponent(Component):
info="Select the DataFrame operation to perform.",
options=[
{"name": "Add Column", "icon": "plus"},
{"name": "Concatenate", "icon": "combine"},
{"name": "Drop Column", "icon": "minus"},
{"name": "Filter", "icon": "filter"},
{"name": "Head", "icon": "arrow-up"},
{"name": "Merge", "icon": "merge"},
{"name": "Rename Column", "icon": "pencil"},
{"name": "Replace Value", "icon": "replace"},
{"name": "Select Columns", "icon": "columns"},
@ -138,6 +143,22 @@ class DataFrameOperationsComponent(Component):
dynamic=True,
show=False,
),
StrInput(
name="merge_on_column",
display_name="Merge On Column",
info="The column name to merge DataFrames on. Must exist in both DataFrames.",
dynamic=True,
show=False,
),
DropdownInput(
name="merge_how",
display_name="Merge Type",
options=["inner", "outer", "left", "right"],
value="inner",
info="Type of merge: inner (intersection), outer (union), left, or right.",
dynamic=True,
show=False,
),
]
outputs = [
@ -161,6 +182,8 @@ class DataFrameOperationsComponent(Component):
"num_rows",
"replace_value",
"replacement_value",
"merge_on_column",
"merge_how",
]
for field in dynamic_fields:
build_config[field]["show"] = False
@ -201,18 +224,27 @@ class DataFrameOperationsComponent(Component):
build_config["replacement_value"]["show"] = True
elif operation_name == "Drop Duplicates":
build_config["column_name"]["show"] = True
elif operation_name == "Merge":
build_config["merge_on_column"]["show"] = True
build_config["merge_how"]["show"] = True
return build_config
def perform_operation(self) -> DataFrame:
df_copy = self.df.copy()
def _get_primary_dataframe(self) -> DataFrame:
"""Get the first DataFrame from input (handles both single and list inputs)."""
if isinstance(self.df, list):
return self.df[0].copy() if self.df else DataFrame()
return self.df.copy()
# Handle SortableListInput format for operation
def perform_operation(self) -> DataFrame:
df_copy = self._get_primary_dataframe()
# Handle SortableListInput format for operation (also supports legacy string format)
operation_input = getattr(self, "operation", [])
if isinstance(operation_input, list) and len(operation_input) > 0:
op = operation_input[0].get("name", "")
if isinstance(operation_input, list):
op = operation_input[0].get("name", "") if operation_input else ""
else:
op = ""
op = operation_input or ""
# If no operation selected, return original DataFrame
if not op:
@ -238,6 +270,10 @@ class DataFrameOperationsComponent(Component):
return self.replace_values(df_copy)
if op == "Drop Duplicates":
return self.drop_duplicates(df_copy)
if op == "Concatenate":
return self.concatenate_dataframes()
if op == "Merge":
return self.merge_dataframes()
msg = f"Unsupported operation: {op}"
logger.error(msg)
raise ValueError(msg)
@ -311,3 +347,65 @@ class DataFrameOperationsComponent(Component):
def drop_duplicates(self, df: DataFrame) -> DataFrame:
return DataFrame(df.drop_duplicates(subset=self.column_name))
def concatenate_dataframes(self) -> DataFrame:
"""Concatenate multiple DataFrames vertically (stack rows)."""
if not isinstance(self.df, list) or len(self.df) == 0:
return self.df.copy() if self.df is not None else DataFrame()
# If only one DataFrame, return it
if len(self.df) == 1:
return self.df[0].copy()
# Concatenate all DataFrames vertically
concatenated = pd.concat(self.df, ignore_index=True)
return DataFrame(concatenated)
def merge_dataframes(self) -> DataFrame:
"""Merge two DataFrames based on a common column (join operation)."""
if not isinstance(self.df, list) or len(self.df) == 0:
return self.df.copy() if self.df is not None else DataFrame()
# If only one DataFrame, return it
if len(self.df) == 1:
return self.df[0].copy()
# Merge requires exactly two DataFrames
max_merge_inputs = 2
if len(self.df) > max_merge_inputs:
msg = f"Merge requires exactly {max_merge_inputs} DataFrames, got {len(self.df)}"
raise ValueError(msg)
df1 = self.df[0].copy()
df2 = self.df[1].copy()
merge_on = getattr(self, "merge_on_column", None)
merge_how = getattr(self, "merge_how", "inner")
# If merge column specified, validate it exists in both DataFrames
if merge_on:
if merge_on not in df1.columns:
msg = f"Column '{merge_on}' not found in first DataFrame. Available: {list(df1.columns)}"
raise ValueError(msg)
if merge_on not in df2.columns:
msg = f"Column '{merge_on}' not found in second DataFrame. Available: {list(df2.columns)}"
raise ValueError(msg)
merged = df1.merge(df2, on=merge_on, how=merge_how, suffixes=("", "_df2"))
else:
merged = df1.merge(df2, left_index=True, right_index=True, how=merge_how, suffixes=("", "_df2"))
# Combine duplicate columns: use df1 value if exists, otherwise df2 value
cols_to_drop = []
for col in merged.columns:
if col.endswith("_df2"):
original_col = col[:-4] # Remove "_df2" suffix
if original_col in merged.columns:
# Coalesce: use original if not null, otherwise use _df2
merged[original_col] = merged[original_col].combine_first(merged[col])
cols_to_drop.append(col)
if cols_to_drop:
merged = merged.drop(columns=cols_to_drop)
return DataFrame(merged)