Repository: jtablesaw/tablesaw
Pull request #940 (2021-05-20 04:20:57)

Add options for keep join keys in both table when appling outer join 
Thanks for contributing.

- [x] Tick to sign-off your agreement to the [Developer Certificate of Origin (DCO) 1.1](https://developercertificate.org)

## Description

**This is a feature related to issue #692.**

What was changed:

I added a set of interface with an additional parameter called  `keepAllJoinKeyColumns` :

- `  public Table rightOuter(
      Table table2,
      boolean allowDuplicateColumnNames,
      boolean keepAllJoinKeyColumns,
      String... col2Names) `
- `  public Table inner(
      Table table2,
      boolean allowDuplicateColumnNames,
      boolean keepAllJoinKeyColumns,
      String... col2Names) `
- `  public Table fullOuter(
      Table table2,
      boolean allowDuplicateColumnNames,
      boolean keepAllJoinKeyColumns,
      String... col2Names)`
- `  public Table leftOuter(
      Table table2,
      boolean allowDuplicateColumnNames,
      boolean keepAllJoinKeyColumns,
      String... col2Names) `

This will work when two tables are doing an outer join, and you want to modify the join key column with null values. Before fixing, one table's join key can be returned, which is not flexible. 
## Testing

Did you add a unit test?
I added 4 unit tests:
  - `public void leftOuterJoinOnAgeMoveInDateKeepAllJoinKeyColumns()` 
  - `public void rightOuterJoinOnAgeMoveInDateKeepAllJoinKeyColumns()`
  - `public void fullOuterJoinOnAgeMoveInDateKeepAllJoinKeyColumns()`
  - `public void innerJoinOnNameHomeAgeKeepAllJoinKeyColumns()` 


Patch:
diff --git a/core/src/main/java/tech/tablesaw/io/fixed/FixedWidthWriteOptions.java b/core/src/main/java/tech/tablesaw/io/fixed/FixedWidthWriteOptions.java
index d541789b..65523ce5 100644
--- a/core/src/main/java/tech/tablesaw/io/fixed/FixedWidthWriteOptions.java
+++ b/core/src/main/java/tech/tablesaw/io/fixed/FixedWidthWriteOptions.java
@@ -217,7 +217,7 @@ public class FixedWidthWriteOptions extends WriteOptions {
       return this;
     }
 
-    public FixedWidthWriteOptions.Builder header(FixedWidthFields columnSpecs) {
+    public FixedWidthWriteOptions.Builder columnSpecs(FixedWidthFields columnSpecs) {
       this.columnSpecs = columnSpecs;
       return this;
     }
diff --git a/core/src/main/java/tech/tablesaw/io/fixed/FixedWidthWriter.java b/core/src/main/java/tech/tablesaw/io/fixed/FixedWidthWriter.java
index 99eda589..34e1f34e 100644
--- a/core/src/main/java/tech/tablesaw/io/fixed/FixedWidthWriter.java
+++ b/core/src/main/java/tech/tablesaw/io/fixed/FixedWidthWriter.java
@@ -97,6 +97,9 @@ public final class FixedWidthWriter implements DataWriter<FixedWidthWriteOptions
 
   protected FixedWidthWriterSettings fixedWidthWriterSettings(FixedWidthWriteOptions options) {
     FixedWidthWriterSettings settings = new FixedWidthWriterSettings();
+    if (options.columnSpecs() != null) {
+      settings = new FixedWidthWriterSettings(options.columnSpecs());
+    }
 
     if (options.autoConfigurationEnabled()) {
       settings.setAutoConfigurationEnabled(options.autoConfigurationEnabled());
diff --git a/core/src/main/java/tech/tablesaw/joining/DataFrameJoiner.java b/core/src/main/java/tech/tablesaw/joining/DataFrameJoiner.java
index f1bb9f7b..874a4964 100644
--- a/core/src/main/java/tech/tablesaw/joining/DataFrameJoiner.java
+++ b/core/src/main/java/tech/tablesaw/joining/DataFrameJoiner.java
@@ -80,10 +80,10 @@ public class DataFrameJoiner {
    */
   public Table inner(boolean allowDuplicateColumnNames, Table... tables) {
     Table joined = table;
-
     for (Table currT : tables) {
       joined =
-          joinInternal(joined, currT, JoinType.INNER, allowDuplicateColumnNames, joinColumnNames);
+          joinInternal(
+              joined, currT, JoinType.INNER, allowDuplicateColumnNames, false, joinColumnNames);
     }
     return joined;
   }
@@ -144,10 +144,35 @@ public class DataFrameJoiner {
    */
   public Table inner(Table table2, boolean allowDuplicateColumnNames, String... col2Names) {
     Table joinedTable;
-    joinedTable = joinInternal(table, table2, JoinType.INNER, allowDuplicateColumnNames, col2Names);
+    joinedTable =
+        joinInternal(table, table2, JoinType.INNER, allowDuplicateColumnNames, false, col2Names);
     return joinedTable;
   }
 
+  /**
+   * Joins the joiner to the table2, using the given columns for the second table and returns the
+   * resulting table
+   *
+   * @param table2 The table to join with
+   * @param allowDuplicateColumnNames if {@code false} the join will fail if any columns other than
+   *     the join column have the same name if {@code true} the join will succeed and duplicate
+   *     columns are renamed*
+   * @param keepAllJoinKeyColumns if {@code false} the join will only keep join key columns in
+   *     table1 if {@code true} the join will return all join key columns in both table, which may
+   *     have difference when there are null values
+   * @param col2Names The columns to join on. If a name refers to a double column, the join is
+   *     performed after rounding to integers.
+   * @return The resulting table
+   */
+  public Table inner(
+      Table table2,
+      boolean allowDuplicateColumnNames,
+      boolean keepAllJoinKeyColumns,
+      String... col2Names) {
+    return joinInternal(
+        table, table2, JoinType.INNER, allowDuplicateColumnNames, keepAllJoinKeyColumns, col2Names);
+  }
+
   /**
    * Joins two tables.
    *
@@ -157,6 +182,9 @@ public class DataFrameJoiner {
    * @param allowDuplicates if {@code false} the join will fail if any columns other than the join
    *     column have the same name if {@code true} the join will succeed and duplicate columns are
    *     renamed
+   * @param keepAllJoinKeyColumns if {@code false} the join will only keep join key columns in
+   *     table1 if {@code true} the join will return all join key columns in both table, which may
+   *     have difference when there are null values
    * @param table2JoinColumnNames The names of the columns in table2 to join on.
    * @return the joined table
    */
@@ -165,6 +193,7 @@ public class DataFrameJoiner {
       Table table2,
       JoinType joinType,
       boolean allowDuplicates,
+      boolean keepAllJoinKeyColumns,
       String... table2JoinColumnNames) {
 
     List<Integer> table2JoinColumnIndexes = getJoinIndexes(table2, table2JoinColumnNames);
@@ -176,13 +205,21 @@ public class DataFrameJoiner {
     // keys.
     Set<Integer> resultIgnoreColIndexes =
         emptyTableFromColumns(
-            result, table1, table2, joinType, allowDuplicates, table2JoinColumnIndexes);
+            result,
+            table1,
+            table2,
+            joinType,
+            allowDuplicates,
+            table2JoinColumnIndexes,
+            keepAllJoinKeyColumns);
 
     validateIndexes(table1Indexes, table2Indexes);
     if (table1.rowCount() == 0 && (joinType == JoinType.LEFT_OUTER || joinType == JoinType.INNER)) {
       // Handle special case of empty table here so it doesn't fall through to the behavior
       // that adds rows for full outer and right outer joins
-      result.removeColumns(Ints.toArray(resultIgnoreColIndexes));
+      if (!keepAllJoinKeyColumns) {
+        result.removeColumns(Ints.toArray(resultIgnoreColIndexes));
+      }
       return result;
     }
 
@@ -202,13 +239,21 @@ public class DataFrameJoiner {
         Selection table2Rows =
             createMultiColSelection(
                 table2, ri, table2Indexes, table2.rowCount(), table2JoinColumnIndexes);
-
-        crossProduct(result, table1, table2, table1Rows, table2Rows, resultIgnoreColIndexes);
+        crossProduct(
+            result,
+            table1,
+            table2,
+            table1Rows,
+            table2Rows,
+            resultIgnoreColIndexes,
+            keepAllJoinKeyColumns);
 
         table2DoneRows = table2DoneRows.or(table2Rows);
         if (table2DoneRows.size() == table2.rowCount()) {
           // Processed all the rows in table1 exit early.
-          result.removeColumns(Ints.toArray(resultIgnoreColIndexes));
+          if (!keepAllJoinKeyColumns) {
+            result.removeColumns(Ints.toArray(resultIgnoreColIndexes));
+          }
           return result;
         }
       }
@@ -227,18 +272,27 @@ public class DataFrameJoiner {
                 table1, ri, table2Indexes, table2.rowCount(), joinColumnIndexes);
         if ((joinType == JoinType.LEFT_OUTER || joinType == JoinType.FULL_OUTER)
             && table2Rows.isEmpty()) {
-          withMissingLeftJoin(result, table1, table1Rows, resultIgnoreColIndexes);
+          withMissingLeftJoin(
+              result, table1, table1Rows, resultIgnoreColIndexes, keepAllJoinKeyColumns);
         } else {
-          crossProduct(result, table1, table2, table1Rows, table2Rows, resultIgnoreColIndexes);
+          crossProduct(
+              result,
+              table1,
+              table2,
+              table1Rows,
+              table2Rows,
+              resultIgnoreColIndexes,
+              keepAllJoinKeyColumns);
         }
-
         table1DoneRows = table1DoneRows.or(table1Rows);
         if (joinType == JoinType.FULL_OUTER || joinType == JoinType.RIGHT_OUTER) {
           // Update done rows in table2 for full Outer.
           table2DoneRows = table2DoneRows.or(table2Rows);
         } else if (table1DoneRows.size() == table1.rowCount()) {
           // Processed all the rows in table1 exit early.
-          result.removeColumns(Ints.toArray(resultIgnoreColIndexes));
+          if (!keepAllJoinKeyColumns) {
+            result.removeColumns(Ints.toArray(resultIgnoreColIndexes));
+          }
           return result;
         }
       }
@@ -253,8 +307,11 @@ public class DataFrameJoiner {
         table2Rows,
         joinType,
         table2JoinColumnIndexes,
-        resultIgnoreColIndexes);
-    result.removeColumns(Ints.toArray(resultIgnoreColIndexes));
+        resultIgnoreColIndexes,
+        keepAllJoinKeyColumns);
+    if (!keepAllJoinKeyColumns) {
+      result.removeColumns(Ints.toArray(resultIgnoreColIndexes));
+    }
     return result;
   }
 
@@ -432,11 +489,45 @@ public class DataFrameJoiner {
     for (Table currT : tables) {
       joined =
           joinInternal(
-              joined, currT, JoinType.FULL_OUTER, allowDuplicateColumnNames, joinColumnNames);
+              joined,
+              currT,
+              JoinType.FULL_OUTER,
+              allowDuplicateColumnNames,
+              false,
+              joinColumnNames);
     }
     return joined;
   }
 
+  /**
+   * Joins the joiner to the table2, using the given columns for the second table and returns the
+   * resulting table
+   *
+   * @param table2 The table to join with
+   * @param allowDuplicateColumnNames if {@code false} the join will fail if any columns other than
+   *     the join column have the same name if {@code true} the join will succeed and duplicate
+   *     columns are renamed
+   * @param keepAllJoinKeyColumns if {@code false} the join will only keep join key columns in
+   *     table1 if {@code true} the join will return all join key columns in both table, which may
+   *     have difference when there are null values
+   * @param col2Names The columns to join on. If a name refers to a double column, the join is
+   *     performed after rounding to integers.
+   * @return The resulting table
+   */
+  public Table fullOuter(
+      Table table2,
+      boolean allowDuplicateColumnNames,
+      boolean keepAllJoinKeyColumns,
+      String... col2Names) {
+    return joinInternal(
+        table,
+        table2,
+        JoinType.FULL_OUTER,
+        allowDuplicateColumnNames,
+        keepAllJoinKeyColumns,
+        col2Names);
+  }
+
   /**
    * Full outer join the joiner to the table2, using the given column for the second table and
    * returns the resulting table
@@ -447,7 +538,7 @@ public class DataFrameJoiner {
    * @return The resulting table
    */
   public Table fullOuter(Table table2, String col2Name) {
-    return joinInternal(table, table2, JoinType.FULL_OUTER, false, col2Name);
+    return joinInternal(table, table2, JoinType.FULL_OUTER, false, false, col2Name);
   }
 
   /**
@@ -474,7 +565,12 @@ public class DataFrameJoiner {
     for (Table table2 : tables) {
       joined =
           joinInternal(
-              joined, table2, JoinType.LEFT_OUTER, allowDuplicateColumnNames, joinColumnNames);
+              joined,
+              table2,
+              JoinType.LEFT_OUTER,
+              allowDuplicateColumnNames,
+              false,
+              joinColumnNames);
     }
     return joined;
   }
@@ -518,7 +614,37 @@ public class DataFrameJoiner {
    * @return The resulting table
    */
   public Table leftOuter(Table table2, boolean allowDuplicateColumnNames, String... col2Names) {
-    return joinInternal(table, table2, JoinType.LEFT_OUTER, allowDuplicateColumnNames, col2Names);
+    return joinInternal(
+        table, table2, JoinType.LEFT_OUTER, allowDuplicateColumnNames, false, col2Names);
+  }
+
+  /**
+   * Joins the joiner to the table2, using the given columns for the second table and returns the
+   * resulting table
+   *
+   * @param table2 The table to join with
+   * @param allowDuplicateColumnNames if {@code false} the join will fail if any columns other than
+   *     the join column have the same name if {@code true} the join will succeed and duplicate
+   *     columns are renamed
+   * @param keepAllJoinKeyColumns if {@code false} the join will only keep join key columns in
+   *     table1 if {@code true} the join will return all join key columns in both table, which may
+   *     have difference when there are null values
+   * @param col2Names The columns to join on. If a name refers to a double column, the join is
+   *     performed after rounding to integers.
+   * @return The resulting table
+   */
+  public Table leftOuter(
+      Table table2,
+      boolean allowDuplicateColumnNames,
+      boolean keepAllJoinKeyColumns,
+      String... col2Names) {
+    return joinInternal(
+        table,
+        table2,
+        JoinType.LEFT_OUTER,
+        allowDuplicateColumnNames,
+        keepAllJoinKeyColumns,
+        col2Names);
   }
 
   /**
@@ -545,7 +671,12 @@ public class DataFrameJoiner {
     for (Table table2 : tables) {
       joined =
           joinInternal(
-              joined, table2, JoinType.RIGHT_OUTER, allowDuplicateColumnNames, joinColumnNames);
+              joined,
+              table2,
+              JoinType.RIGHT_OUTER,
+              allowDuplicateColumnNames,
+              false,
+              joinColumnNames);
       joinColumnIndexes.clear();
       joinColumnIndexes.addAll(getJoinIndexes(joined, joinColumnNames));
     }
@@ -591,7 +722,37 @@ public class DataFrameJoiner {
    * @return The resulting table
    */
   public Table rightOuter(Table table2, boolean allowDuplicateColumnNames, String... col2Names) {
-    return joinInternal(table, table2, JoinType.RIGHT_OUTER, allowDuplicateColumnNames, col2Names);
+    return joinInternal(
+        table, table2, JoinType.RIGHT_OUTER, allowDuplicateColumnNames, false, col2Names);
+  }
+
+  /**
+   * Joins the joiner to the table2, using the given columns for the second table and returns the
+   * resulting table
+   *
+   * @param table2 The table to join with
+   * @param allowDuplicateColumnNames if {@code false} the join will fail if any columns other than
+   *     the join column have the same name if {@code true} the join will succeed and duplicate
+   *     columns are renamed
+   * @param keepAllJoinKeyColumns if {@code false} the join will only keep join key columns in
+   *     table1 if {@code true} the join will return all join key columns in both table, which may
+   *     have difference when there are null values
+   * @param col2Names The columns to join on. If a name refers to a double column, the join is
+   *     performed after rounding to integers.
+   * @return The resulting table
+   */
+  public Table rightOuter(
+      Table table2,
+      boolean allowDuplicateColumnNames,
+      boolean keepAllJoinKeyColumns,
+      String... col2Names) {
+    return joinInternal(
+        table,
+        table2,
+        JoinType.RIGHT_OUTER,
+        allowDuplicateColumnNames,
+        keepAllJoinKeyColumns,
+        col2Names);
   }
 
   /**
@@ -617,7 +778,8 @@ public class DataFrameJoiner {
       Table table2,
       JoinType joinType,
       boolean allowDuplicates,
-      List<Integer> table2JoinColumnIndexes) {
+      List<Integer> table2JoinColumnIndexes,
+      boolean keepTable2JoinKeyColumns) {
 
     Column<?>[] cols =
         Streams.concat(table1.columns().stream(), table2.columns().stream())
@@ -632,13 +794,17 @@ public class DataFrameJoiner {
     for (int c = 0; c < cols.length; c++) {
       if (joinType == JoinType.RIGHT_OUTER) {
         if (c < table1.columnCount() && joinColumnIndexes.contains(c)) {
-          cols[c].setName("Placeholder_" + ignoreColumns.size());
+          if (!keepTable2JoinKeyColumns) {
+            cols[c].setName("Placeholder_" + ignoreColumns.size());
+          }
           ignoreColumns.add(c);
         }
       } else {
         int table2Index = c - table1.columnCount();
         if (c >= table1.columnCount() && table2JoinColumnIndexes.contains(table2Index)) {
-          cols[c].setName("Placeholder_" + ignoreColumns.size());
+          if (!keepTable2JoinKeyColumns) {
+            cols[c].setName("Placeholder_" + ignoreColumns.size());
+          }
           ignoreColumns.add(c);
         }
       }
@@ -683,9 +849,10 @@ public class DataFrameJoiner {
       Table table2,
       Selection table1Rows,
       Selection table2Rows,
-      Set<Integer> ignoreColumns) {
+      Set<Integer> ignoreColumns,
+      boolean keepTable2JoinKeyColumns) {
     for (int c = 0; c < table1.columnCount() + table2.columnCount(); c++) {
-      if (ignoreColumns.contains(c)) {
+      if (!keepTable2JoinKeyColumns && ignoreColumns.contains(c)) {
         continue;
       }
       int table2Index = c - table1.columnCount();
@@ -709,9 +876,13 @@ public class DataFrameJoiner {
    */
   @SuppressWarnings({"rawtypes", "unchecked"})
   private void withMissingLeftJoin(
-      Table destination, Table table1, Selection table1Rows, Set<Integer> ignoreColumns) {
+      Table destination,
+      Table table1,
+      Selection table1Rows,
+      Set<Integer> ignoreColumns,
+      boolean keepTable2JoinKeyColumns) {
     for (int c = 0; c < destination.columnCount(); c++) {
-      if (ignoreColumns.contains(c)) {
+      if (!keepTable2JoinKeyColumns && ignoreColumns.contains(c)) {
         continue;
       }
       if (c < table1.columnCount()) {
@@ -739,7 +910,8 @@ public class DataFrameJoiner {
       Selection table2Rows,
       JoinType joinType,
       List<Integer> col2Indexes,
-      Set<Integer> ignoreColumns) {
+      Set<Integer> ignoreColumns,
+      boolean keepTable2JoinKeyColumns) {
 
     // Add index data from table2 into join column positions in table one.
     if (joinType == JoinType.FULL_OUTER) {
@@ -752,8 +924,10 @@ public class DataFrameJoiner {
     }
 
     for (int c = 0; c < destination.columnCount(); c++) {
-      if (ignoreColumns.contains(c) || joinColumnIndexes.contains(c)) {
-        continue;
+      if (!keepTable2JoinKeyColumns) {
+        if (ignoreColumns.contains(c) || joinColumnIndexes.contains(c)) {
+          continue;
+        }
       }
       if (c < table1ColCount) {
         for (int r1 = 0; r1 < table2Rows.size(); r1++) {
diff --git a/jsplot/src/main/java/tech/tablesaw/plotly/api/ContourPlot.java b/jsplot/src/main/java/tech/tablesaw/plotly/api/ContourPlot.java
new file mode 100644
index 00000000..7f52b6c3
--- /dev/null
+++ b/jsplot/src/main/java/tech/tablesaw/plotly/api/ContourPlot.java
@@ -0,0 +1,32 @@
+package tech.tablesaw.plotly.api;
+
+import tech.tablesaw.api.Table;
+import tech.tablesaw.columns.Column;
+import tech.tablesaw.plotly.components.Figure;
+import tech.tablesaw.plotly.components.Layout;
+import tech.tablesaw.plotly.traces.ContourTrace;
+import tech.tablesaw.util.DoubleArrays;
+
+import java.util.List;
+
+public class ContourPlot {
+
+  private ContourPlot() {
+  }
+
+  public static Figure create(String title, Table table, String categoryCol1, String categoryCol2) {
+    Layout layout = Layout.builder(title).build();
+
+    Table counts = table.xTabCounts(categoryCol1, categoryCol2);
+    counts = counts.dropRows(counts.rowCount() - 1);
+    List<Column<?>> columns = counts.columns();
+    columns.remove(counts.columnCount() - 1);
+    Column<?> yColumn = columns.remove(0);
+    double[][] z = DoubleArrays.to2dArray(counts.numericColumns());
+
+    Object[] x = counts.columnNames().toArray();
+    Object[] y = yColumn.asObjectArray();
+    ContourTrace trace = ContourTrace.builder(x, y, z).build();
+    return new Figure(layout, trace);
+  }
+}
diff --git a/jsplot/src/main/java/tech/tablesaw/plotly/traces/ContourTrace.java b/jsplot/src/main/java/tech/tablesaw/plotly/traces/ContourTrace.java
new file mode 100644
index 00000000..aebee67c
--- /dev/null
+++ b/jsplot/src/main/java/tech/tablesaw/plotly/traces/ContourTrace.java
@@ -0,0 +1,95 @@
+package tech.tablesaw.plotly.traces;
+
+import com.mitchellbosecke.pebble.error.PebbleException;
+import com.mitchellbosecke.pebble.template.PebbleTemplate;
+
+import java.io.IOException;
+import java.io.StringWriter;
+import java.io.UncheckedIOException;
+import java.io.Writer;
+import java.util.Map;
+
+import static tech.tablesaw.plotly.Utils.dataAsString;
+
+public class ContourTrace extends AbstractTrace {
+
+  private final Object[] x;
+  private final Object[] y;
+  private final double[][] z;
+  private final String type;
+
+  public ContourTrace(ContourBuilder builder) {
+    super(builder);
+    this.x = builder.x;
+    this.y = builder.y;
+    this.z = builder.z;
+    this.type = builder.getType();
+  }
+
+  @Override
+  public String asJavascript(int i) {
+    Writer writer = new StringWriter();
+    PebbleTemplate compiledTemplate;
+
+    try {
+      compiledTemplate = engine.getTemplate("trace_template.html");
+      compiledTemplate.evaluate(writer, getContext());
+    } catch (PebbleException e) {
+      throw new IllegalStateException(e);
+    } catch (IOException e) {
+      throw new UncheckedIOException(e);
+    }
+    return writer.toString();
+  }
+
+  @Override
+  protected Map<String, Object> getContext() {
+
+    Map<String, Object> context = super.getContext();
+    context.put("variableName", "trace0");
+    context.put("x", dataAsString(x));
+    context.put("y", dataAsString(y));
+    context.put("z", dataAsString(z));
+    context.put("type", type);
+    return context;
+  }
+
+  public static ContourTrace.ContourBuilder builder(Object[] x, Object[] y, double[][] z) {
+    return new ContourTrace.ContourBuilder(x, y, z);
+  }
+
+  public static class ContourBuilder extends TraceBuilder {
+
+    private static final String type = "contour";
+    private final Object[] x;
+    private final Object[] y;
+    private final double[][] z;
+
+    ContourBuilder(Object[] x, Object[] y, double[][] z) {
+      this.x = x;
+      this.y = y;
+      this.z = z;
+    }
+
+    @Override
+    public ContourTrace.ContourBuilder xAxis(String xAxis) {
+      super.xAxis(xAxis);
+      return this;
+    }
+
+    @Override
+    public ContourTrace.ContourBuilder yAxis(String yAxis) {
+      super.yAxis(yAxis);
+      return this;
+    }
+
+    public ContourTrace build() {
+      return new ContourTrace(this);
+    }
+
+    @Override
+    protected String getType() {
+      return type;
+    }
+  }
+}


Test patch:
diff --git a/core/src/test/java/tech/tablesaw/io/fixed/FixedWidthWriterTest.java b/core/src/test/java/tech/tablesaw/io/fixed/FixedWidthWriterTest.java
new file mode 100644
index 00000000..f2d30336
--- /dev/null
+++ b/core/src/test/java/tech/tablesaw/io/fixed/FixedWidthWriterTest.java
@@ -0,0 +1,46 @@
+package tech.tablesaw.io.fixed;
+
+import static java.lang.Double.NaN;
+import static org.junit.jupiter.api.Assertions.*;
+
+import com.univocity.parsers.fixed.FixedWidthFields;
+import java.io.*;
+import org.junit.jupiter.api.Test;
+import tech.tablesaw.api.DoubleColumn;
+import tech.tablesaw.api.Table;
+
+public class FixedWidthWriterTest {
+  private static final String LINE_END = System.lineSeparator();
+
+  private double[] v1 = {1, 2, NaN};
+  private double[] v2 = {1, 2, NaN};
+  private Table table =
+      Table.create("t", DoubleColumn.create("v1", v1), DoubleColumn.create("v2", v2));
+
+  @Test
+  public void testOutputInFixedWidthFormat() {
+    ByteArrayOutputStream baos = new ByteArrayOutputStream();
+    FixedWidthFields fwf = new FixedWidthFields(10, 10);
+    FixedWidthWriteOptions options =
+        new FixedWidthWriteOptions.Builder(baos)
+            .header(true)
+            .columnSpecs(fwf)
+            .autoConfigurationEnabled(false)
+            .build();
+    FixedWidthWriter writer = new FixedWidthWriter();
+    writer.write(table, options);
+
+    String output = baos.toString();
+    assertEquals(
+        "v1________v2________"
+            + LINE_END
+            + "1.0_______1.0_______"
+            + LINE_END
+            + "2.0_______2.0_______"
+            + LINE_END
+            + "____________________"
+            + LINE_END
+            + "",
+        output);
+  }
+}
diff --git a/core/src/test/java/tech/tablesaw/joining/DataFrameJoinerTest.java b/core/src/test/java/tech/tablesaw/joining/DataFrameJoinerTest.java
index 73c1f671..bd271d81 100644
--- a/core/src/test/java/tech/tablesaw/joining/DataFrameJoinerTest.java
+++ b/core/src/test/java/tech/tablesaw/joining/DataFrameJoinerTest.java
@@ -1438,6 +1438,46 @@ public class DataFrameJoinerTest {
     assertEquals(9, joined.rowCount());
   }
 
+  @Test
+  public void leftOuterJoin_keepAllJoinKeyColumns() {
+    Table table1 = createANIMALHOMES();
+    Table table2 = createDOUBLEINDEXEDPEOPLENameHomeAgeMoveInDate();
+    Table joined =
+        table1.joinOn("Age", "MoveInDate").leftOuter(table2, true, true, "Age", "MoveInDate");
+    assertEquals(10, joined.columnCount());
+    assertEquals(9, joined.rowCount());
+  }
+
+  @Test
+  public void rightOuterJoin_keepAllJoinKeyColumns() {
+    Table table1 = createANIMALHOMES();
+    Table table2 = createDOUBLEINDEXEDPEOPLENameHomeAgeMoveInDate();
+    Table joined =
+        table1.joinOn("Age", "MoveInDate").rightOuter(table2, true, true, "Age", "MoveInDate");
+    assertEquals(10, joined.columnCount());
+    assertEquals(6, joined.rowCount());
+  }
+
+  @Test
+  public void fullOuter_keepAllJoinKeyColumns() {
+    Table table1 = createANIMALHOMES();
+    Table table2 = createDOUBLEINDEXEDPEOPLENameHomeAgeMoveInDate();
+    Table joined =
+        table1.joinOn("Age", "MoveInDate").fullOuter(table2, true, true, "Age", "MoveInDate");
+    assertEquals(10, joined.columnCount());
+    assertEquals(12, joined.rowCount());
+  }
+
+  @Test
+  public void innerJoin_keepAllJoinKeyColumns() {
+    Table table1 = createANIMALHOMES();
+    Table table2 = createDOUBLEINDEXEDPEOPLENameHomeAgeMoveInDate();
+    Table joined =
+        table1.joinOn("Name", "Home", "Age").inner(table2, true, true, "Name", "Home", "Age");
+    assertEquals(10, joined.columnCount());
+    assertEquals(1, joined.rowCount());
+  }
+
   @Test
   public void rightOuterJoinOnAgeMoveInDate() {
     Table table1 = createANIMALHOMES();
diff --git a/jsplot/src/test/java/tech/tablesaw/plotly/ContourTest.java b/jsplot/src/test/java/tech/tablesaw/plotly/ContourTest.java
new file mode 100644
index 00000000..f4a4e585
--- /dev/null
+++ b/jsplot/src/test/java/tech/tablesaw/plotly/ContourTest.java
@@ -0,0 +1,50 @@
+package tech.tablesaw.plotly;
+
+import org.junit.jupiter.api.Disabled;
+import org.junit.jupiter.api.Test;
+import tech.tablesaw.api.StringColumn;
+import tech.tablesaw.api.Table;
+import tech.tablesaw.plotly.api.ContourPlot;
+import tech.tablesaw.plotly.components.Figure;
+import tech.tablesaw.plotly.traces.ContourTrace;
+
+import java.io.IOException;
+
+@Disabled
+public class ContourTest {
+
+  private final Object[] x = {-9, -6, -5, -3, -1};
+  private final Object[] y = {0, 1, 4, 5, 7};
+  private final double[][] z = {
+      {10, 10.625, 12.5, 15.625, 20},
+      {5.625, 6.25, 8.125, 11.25, 15.625},
+      {2.5, 3.125, 5.0, 8.125, 12.5},
+      {0.625, 1.25, 3.125, 6.25, 10.625},
+      {0, 0.625, 2.5, 5.625, 10}
+  };
+
+  @Test
+  public void testAsJavascript() {
+    ContourTrace trace = ContourTrace.builder(x, y, z).build();
+    System.out.println(trace.asJavascript(1));
+  }
+
+  @Test
+  public void testContourTrace() {
+    ContourTrace trace = ContourTrace.builder(x, y, z).build();
+    Figure figure = new Figure(trace);
+    Plot.show(figure);
+  }
+
+  @Test
+  public void testContourPlot() throws IOException {
+    Table table = Table.read().csv("../data/bush.csv");
+    StringColumn yearsMonth = table.dateColumn("date").yearMonth();
+    String name = "Year and month";
+    yearsMonth.setName(name);
+    table.addColumns(yearsMonth);
+
+    Figure figure = ContourPlot.create("Polls conducted by year and month", table, name, "who");
+    Plot.show(figure);
+  }
+}