Repository: jtablesaw/tablesaw
Pull request #941 (2021-05-20 10:21:32)

Support for not closing the output stream opened by user
Thanks for contributing.

- [x] Tick to sign-off your agreement to the [Developer Certificate of Origin (DCO) 1.1](https://developercertificate.org)

## Description

Add a boolean attribute `autoclose` in `WriteOptions` and `Builder`, whose default value is true. When `Builder` is constructed with OutputStream or Writer, set `autoclose` as false. 

Corresponding issue is #750 

## Testing

Yes. 2 unit tests for testing whether FileOutputStream and OutputStreamWriter close.



Patch:
diff --git a/core/src/main/java/tech/tablesaw/columns/numbers/DoubleParser.java b/core/src/main/java/tech/tablesaw/columns/numbers/DoubleParser.java
index f6ac951a..4eb74b3d 100644
--- a/core/src/main/java/tech/tablesaw/columns/numbers/DoubleParser.java
+++ b/core/src/main/java/tech/tablesaw/columns/numbers/DoubleParser.java
@@ -1,6 +1,8 @@
 package tech.tablesaw.columns.numbers;
 
 import com.google.common.collect.Lists;
+import java.text.NumberFormat;
+import java.text.ParseException;
 import tech.tablesaw.api.ColumnType;
 import tech.tablesaw.columns.AbstractColumnParser;
 import tech.tablesaw.io.ReadOptions;
@@ -24,9 +26,14 @@ public class DoubleParser extends AbstractColumnParser<Double> {
       return true;
     }
     try {
-      Double.parseDouble(AbstractColumnParser.remove(s, ','));
+      if (isPercent(AbstractColumnParser.remove(s, ','))) {
+        s = AbstractColumnParser.remove(s, ',');
+        Number number = NumberFormat.getPercentInstance().parse(s);
+      } else {
+        Double.parseDouble(AbstractColumnParser.remove(s, ','));
+      }
       return true;
-    } catch (NumberFormatException e) {
+    } catch (NumberFormatException | ParseException e) {
       // it's all part of the plan
       return false;
     }
@@ -42,6 +49,14 @@ public class DoubleParser extends AbstractColumnParser<Double> {
     if (isMissing(s)) {
       return DoubleColumnType.missingValueIndicator();
     }
+    if (isPercent(AbstractColumnParser.remove(s, ','))) {
+      s = AbstractColumnParser.remove(s, ',').substring(0, s.length() - 1);
+      return Double.parseDouble(s) / 100.0;
+    }
     return Double.parseDouble(AbstractColumnParser.remove(s, ','));
   }
+
+  private boolean isPercent(String s) {
+    return s.charAt(s.length() - 1) == '%';
+  }
 }
diff --git a/core/src/main/java/tech/tablesaw/io/WriteOptions.java b/core/src/main/java/tech/tablesaw/io/WriteOptions.java
index f165d1d1..5f884641 100644
--- a/core/src/main/java/tech/tablesaw/io/WriteOptions.java
+++ b/core/src/main/java/tech/tablesaw/io/WriteOptions.java
@@ -9,8 +9,16 @@ public class WriteOptions {
 
   protected final Destination dest;
 
+  /**
+   * This value is not exposed as an actual option. It will determine whether the output stream
+   * automatically closes after the table has been output. The default value is false, and it just
+   * set true for File output.
+   */
+  protected final boolean autoClose;
+
   protected WriteOptions(Builder builder) {
     this.dest = builder.dest;
+    this.autoClose = builder.autoClose;
   }
 
   public Destination destination() {
@@ -20,6 +28,7 @@ public class WriteOptions {
   public static class Builder {
 
     protected Destination dest;
+    protected boolean autoClose = false;
 
     protected Builder(Destination dest) {
       this.dest = dest;
@@ -35,6 +44,7 @@ public class WriteOptions {
 
     protected Builder(File dest) throws IOException {
       this.dest = new Destination(dest);
+      this.autoClose = true;
     }
   }
 }
diff --git a/core/src/main/java/tech/tablesaw/io/csv/CsvWriteOptions.java b/core/src/main/java/tech/tablesaw/io/csv/CsvWriteOptions.java
index ac18ecb7..4153d07c 100644
--- a/core/src/main/java/tech/tablesaw/io/csv/CsvWriteOptions.java
+++ b/core/src/main/java/tech/tablesaw/io/csv/CsvWriteOptions.java
@@ -90,6 +90,10 @@ public class CsvWriteOptions extends WriteOptions {
     return dateFormatter;
   }
 
+  public boolean autoClose() {
+    return autoClose;
+  }
+
   public static Builder builder(Destination dest) {
     return new Builder(dest);
   }
diff --git a/core/src/main/java/tech/tablesaw/io/csv/CsvWriter.java b/core/src/main/java/tech/tablesaw/io/csv/CsvWriter.java
index c2cbfc5b..e9d362f6 100644
--- a/core/src/main/java/tech/tablesaw/io/csv/CsvWriter.java
+++ b/core/src/main/java/tech/tablesaw/io/csv/CsvWriter.java
@@ -61,7 +61,7 @@ public final class CsvWriter implements DataWriter<CsvWriteOptions> {
     } finally {
       if (csvWriter != null) {
         csvWriter.flush();
-        csvWriter.close();
+        if (options.autoClose()) csvWriter.close();
       }
     }
   }
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 65523ce5..e0a3020f 100644
--- a/core/src/main/java/tech/tablesaw/io/fixed/FixedWidthWriteOptions.java
+++ b/core/src/main/java/tech/tablesaw/io/fixed/FixedWidthWriteOptions.java
@@ -142,6 +142,10 @@ public class FixedWidthWriteOptions extends WriteOptions {
     return normalizedNewline;
   }
 
+  public boolean autoClose() {
+    return autoClose;
+  }
+
   public static Builder builder(Destination destination) throws IOException {
     return new Builder(destination);
   }
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 34e1f34e..56bce22b 100644
--- a/core/src/main/java/tech/tablesaw/io/fixed/FixedWidthWriter.java
+++ b/core/src/main/java/tech/tablesaw/io/fixed/FixedWidthWriter.java
@@ -65,7 +65,7 @@ public final class FixedWidthWriter implements DataWriter<FixedWidthWriteOptions
     } finally {
       if (fixedWidthWriter != null) {
         fixedWidthWriter.flush();
-        fixedWidthWriter.close();
+        if (options.autoClose()) fixedWidthWriter.close();
       }
     }
   }
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/data/currency_percent.csv b/data/currency_percent.csv
new file mode 100644
index 00000000..ceddacb5
--- /dev/null
+++ b/data/currency_percent.csv
@@ -0,0 +1,4 @@
+Currency,Percentage,double
+$10.3,1.32%,13.14
+$50.10,32.768%,35
+$10,100%,93.131
\ No newline at end of file
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/api/DoubleParserTest.java b/core/src/test/java/tech/tablesaw/api/DoubleParserTest.java
new file mode 100644
index 00000000..e6032a28
--- /dev/null
+++ b/core/src/test/java/tech/tablesaw/api/DoubleParserTest.java
@@ -0,0 +1,52 @@
+/*
+ * 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.
+ */
+
+package tech.tablesaw.api;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import org.junit.jupiter.api.Test;
+import tech.tablesaw.columns.numbers.DoubleParser;
+
+class DoubleParserTest {
+  @Test
+  void testCanParse1() {
+    DoubleParser doubleParser = new DoubleParser(ColumnType.DOUBLE);
+    assertTrue(doubleParser.canParse("1.3"));
+  }
+
+  @Test
+  void testCanParse2() {
+    DoubleParser doubleParser = new DoubleParser(ColumnType.DOUBLE);
+    assertTrue(doubleParser.canParse("1.3%"));
+  }
+
+  @Test
+  void testCanParse3() {
+    DoubleParser doubleParser = new DoubleParser(ColumnType.DOUBLE);
+    assertFalse(doubleParser.canParse("%"));
+  }
+
+  @Test
+  void testParseDouble1() {
+    DoubleParser doubleParser = new DoubleParser(ColumnType.DOUBLE);
+    assertEquals(1.3, doubleParser.parseDouble("1.3"));
+  }
+
+  @Test
+  void testParseDouble2() {
+    DoubleParser doubleParser = new DoubleParser(ColumnType.DOUBLE);
+    assertEquals(0.012, doubleParser.parseDouble("1.2%"));
+  }
+}
diff --git a/core/src/test/java/tech/tablesaw/io/DataFrameWriterTest.java b/core/src/test/java/tech/tablesaw/io/DataFrameWriterTest.java
index 77bbb21b..c52c76e5 100644
--- a/core/src/test/java/tech/tablesaw/io/DataFrameWriterTest.java
+++ b/core/src/test/java/tech/tablesaw/io/DataFrameWriterTest.java
@@ -2,9 +2,12 @@ package tech.tablesaw.io;
 
 import static java.lang.Double.NaN;
 import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.fail;
 
-import java.io.ByteArrayOutputStream;
-import java.io.OutputStreamWriter;
+import java.io.*;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
 import org.junit.jupiter.api.Test;
 import tech.tablesaw.api.DoubleColumn;
 import tech.tablesaw.api.Table;
@@ -38,4 +41,74 @@ public class DataFrameWriterTest {
         "v,v2" + LINE_END + "1.0,1.0" + LINE_END + "2.0,2.0" + LINE_END + "," + LINE_END + "",
         output);
   }
+
+  @Test
+  public void testFileOutputStreamWhetherClose() throws IOException {
+    // Create directory if it doesn't exist
+    String DEFAULT_OUTPUT_FOLDER = "../testoutput";
+    Path path = Paths.get(DEFAULT_OUTPUT_FOLDER, "testOutput.csv");
+    try {
+      Files.createDirectories(path.getParent());
+    } catch (IOException e) {
+      throw new UncheckedIOException(e);
+    }
+
+    File file = path.toFile();
+    FileOutputStream fos = new FileOutputStream(file);
+    table.write().csv(fos);
+
+    // Read file content
+    FileInputStream fis = new FileInputStream(file);
+    byte[] filecontent = new byte[(int) file.length()];
+    fis.read(filecontent);
+    String output = new String(filecontent);
+
+    assertEquals(
+        "v,v2" + LINE_END + "1.0,1.0" + LINE_END + "2.0,2.0" + LINE_END + "," + LINE_END + "",
+        output);
+
+    // cannot access the status of fos, so write again to test whether it close
+    try {
+      table.write().csv(fos);
+
+      fis = new FileInputStream(file);
+      filecontent = new byte[(int) file.length()];
+      fis.read(filecontent);
+      output = new String(filecontent);
+
+      assertEquals(
+          "v,v2" + LINE_END + "1.0,1.0" + LINE_END + "2.0,2.0" + LINE_END + "," + LINE_END + "v,v2"
+              + LINE_END + "1.0,1.0" + LINE_END + "2.0,2.0" + LINE_END + "," + LINE_END + "",
+          output);
+    } catch (Exception e) {
+      fail(e);
+    } finally {
+      file.delete();
+    }
+  }
+
+  @Test
+  public void testOutputStreamWriterWhetherClose() {
+    ByteArrayOutputStream baos = new ByteArrayOutputStream();
+    OutputStreamWriter osw = new OutputStreamWriter(baos);
+    table.write().csv(osw);
+    String output = baos.toString();
+
+    assertEquals(
+        "v,v2" + LINE_END + "1.0,1.0" + LINE_END + "2.0,2.0" + LINE_END + "," + LINE_END + "",
+        output);
+
+    // cannot access the status of osw directly, so write again to test whether it close
+    try {
+      table.write().csv(osw);
+      output = baos.toString();
+
+      assertEquals(
+          "v,v2" + LINE_END + "1.0,1.0" + LINE_END + "2.0,2.0" + LINE_END + "," + LINE_END + "v,v2"
+              + LINE_END + "1.0,1.0" + LINE_END + "2.0,2.0" + LINE_END + "," + LINE_END + "",
+          output);
+    } catch (Exception e) {
+      fail(e);
+    }
+  }
 }
diff --git a/core/src/test/java/tech/tablesaw/io/csv/CsvReaderTest.java b/core/src/test/java/tech/tablesaw/io/csv/CsvReaderTest.java
index 4327f4fa..a4bef783 100644
--- a/core/src/test/java/tech/tablesaw/io/csv/CsvReaderTest.java
+++ b/core/src/test/java/tech/tablesaw/io/csv/CsvReaderTest.java
@@ -49,10 +49,13 @@ import org.junit.jupiter.api.Test;
 import tech.tablesaw.api.ColumnType;
 import tech.tablesaw.api.DateColumn;
 import tech.tablesaw.api.DateTimeColumn;
+import tech.tablesaw.api.DoubleColumn;
 import tech.tablesaw.api.LongColumn;
 import tech.tablesaw.api.ShortColumn;
 import tech.tablesaw.api.StringColumn;
 import tech.tablesaw.api.Table;
+import tech.tablesaw.columns.numbers.DoubleColumnType;
+import tech.tablesaw.columns.numbers.NumberColumnFormatter;
 import tech.tablesaw.io.AddCellToColumnException;
 
 /** Tests for CSV Reading */
@@ -846,6 +849,26 @@ public class CsvReaderTest {
     assertEquals(table.get(0, 0), out.get(0, 0));
   }
 
+  @Test
+  public void testReadCsvWithPercentage1() throws IOException {
+    Table table = Table.read().csv(CsvReadOptions.builder("../data/currency_percent.csv"));
+    assertEquals(DoubleColumnType.instance(), table.columnTypes()[1]);
+    assertEquals(DoubleColumnType.instance(), table.columnTypes()[2]);
+  }
+
+  @Test
+  public void testReadCsvWithPercentage2() throws IOException {
+    Table table = Table.read().csv(CsvReadOptions.builder("../data/currency_percent.csv"));
+    DoubleColumn column = (DoubleColumn) table.column(1);
+    assertEquals("0.0132", column.getString(0));
+    assertEquals("0.32768", column.getString(1));
+    assertEquals("1", column.getString(2));
+    column.setPrintFormatter(NumberColumnFormatter.percent(2));
+    assertEquals("1.32%", column.getString(0));
+    assertEquals("32.77%", column.getString(1));
+    assertEquals("100.00%", column.getString(2));
+  }
+
   @Test
   public void testSkipRowsWithInvalidColumnCount() throws IOException {
     Table table =
diff --git a/core/src/test/java/tech/tablesaw/io/fixed/FixedWidthWriterTest.java b/core/src/test/java/tech/tablesaw/io/fixed/FixedWidthWriterTest.java
index f2d30336..3f3a2c8d 100644
--- a/core/src/test/java/tech/tablesaw/io/fixed/FixedWidthWriterTest.java
+++ b/core/src/test/java/tech/tablesaw/io/fixed/FixedWidthWriterTest.java
@@ -5,6 +5,9 @@ import static org.junit.jupiter.api.Assertions.*;
 
 import com.univocity.parsers.fixed.FixedWidthFields;
 import java.io.*;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
 import org.junit.jupiter.api.Test;
 import tech.tablesaw.api.DoubleColumn;
 import tech.tablesaw.api.Table;
@@ -43,4 +46,140 @@ public class FixedWidthWriterTest {
             + "",
         output);
   }
+
+  @Test
+  public void testFileOutputStreamWhetherClose() throws IOException {
+    // Create directory if it doesn't exist
+    String DEFAULT_OUTPUT_FOLDER = "../testoutput";
+    Path path = Paths.get(DEFAULT_OUTPUT_FOLDER, "testOutput.txt");
+    try {
+      Files.createDirectories(path.getParent());
+    } catch (IOException e) {
+      throw new UncheckedIOException(e);
+    }
+
+    File file = path.toFile();
+    FileOutputStream fos = new FileOutputStream(file);
+    FixedWidthFields fwf = new FixedWidthFields(10, 10);
+
+    FixedWidthWriteOptions options =
+        new FixedWidthWriteOptions.Builder(fos)
+            .header(true)
+            .autoConfigurationEnabled(false)
+            .columnSpecs(fwf)
+            .build();
+    FixedWidthWriter writer = new FixedWidthWriter();
+    writer.write(table, options);
+
+    // Read file content
+    FileInputStream fis = new FileInputStream(file);
+    byte[] filecontent = new byte[(int) file.length()];
+    fis.read(filecontent);
+    String output = new String(filecontent);
+
+    assertEquals(
+        "v1________v2________"
+            + LINE_END
+            + "1.0_______1.0_______"
+            + LINE_END
+            + "2.0_______2.0_______"
+            + LINE_END
+            + "____________________"
+            + LINE_END
+            + "",
+        output);
+
+    // cannot access the status of fos, so write again to test whether it close
+    try {
+      writer.write(table, options);
+
+      fis = new FileInputStream(file);
+      filecontent = new byte[(int) file.length()];
+      fis.read(filecontent);
+      output = new String(filecontent);
+
+      assertEquals(
+          "v1________v2________"
+              + LINE_END
+              + "1.0_______1.0_______"
+              + LINE_END
+              + "2.0_______2.0_______"
+              + LINE_END
+              + "____________________"
+              + LINE_END
+              + ""
+              + "v1________v2________"
+              + LINE_END
+              + "1.0_______1.0_______"
+              + LINE_END
+              + "2.0_______2.0_______"
+              + LINE_END
+              + "____________________"
+              + LINE_END
+              + "",
+          output);
+    } catch (Exception e) {
+      fail(e);
+    } finally {
+      file.delete();
+    }
+  }
+
+  @Test
+  public void testOutputStreamWriterWhetherClose() throws IOException {
+    ByteArrayOutputStream baos = new ByteArrayOutputStream();
+    OutputStreamWriter osw = new OutputStreamWriter(baos);
+    FixedWidthFields fwf = new FixedWidthFields(10, 10);
+
+    FixedWidthWriteOptions options =
+        new FixedWidthWriteOptions.Builder(osw)
+            .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);
+
+    // cannot access the status of osw directly, so write again to test whether it close
+    try {
+      writer.write(table, options);
+      output = baos.toString();
+
+      assertEquals(
+          "v1________v2________"
+              + LINE_END
+              + "1.0_______1.0_______"
+              + LINE_END
+              + "2.0_______2.0_______"
+              + LINE_END
+              + "____________________"
+              + LINE_END
+              + ""
+              + "v1________v2________"
+              + LINE_END
+              + "1.0_______1.0_______"
+              + LINE_END
+              + "2.0_______2.0_______"
+              + LINE_END
+              + "____________________"
+              + LINE_END
+              + "",
+          output);
+    } catch (Exception e) {
+      fail(e);
+    }
+  }
 }
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);
+  }
+}