Repository: jtablesaw/tablesaw
Pull request #1260 (2024-08-03 12:16:29)

Jsonl support
- [x ] Tick to sign-off your agreement to the [Developer Certificate of Origin (DCO) 1.1](https://developercertificate.org)

Added support for Json object-per-line, which seems to be common in LLM development (e.g. see various datasets at huggingface.co). Tests added (copied from Json array support).


Patch:
diff --git a/.gitignore b/.gitignore
index ac03e2a5..9bd6972f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -31,3 +31,6 @@ bin/
 nbproject/private/
 nbactions.xml
 nb-configuration.xml
+
+# VSCode
+.vscode/
diff --git a/.mvn/jvm.config b/.mvn/jvm.config
new file mode 100644
index 00000000..79ecf929
--- /dev/null
+++ b/.mvn/jvm.config
@@ -0,0 +1,1 @@
+--add-exports jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED --add-exports jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED --add-exports jdk.compiler/com.sun.tools.javac.parser=ALL-UNNAMED --add-exports jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED --add-exports jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED
diff --git a/core/src/main/java/tech/tablesaw/api/ColumnType.java b/core/src/main/java/tech/tablesaw/api/ColumnType.java
index 0ca61683..12d22b7a 100644
--- a/core/src/main/java/tech/tablesaw/api/ColumnType.java
+++ b/core/src/main/java/tech/tablesaw/api/ColumnType.java
@@ -82,16 +82,11 @@ public interface ColumnType {
   /** TODO: Research this method to provide a good comment */
   AbstractColumnParser<?> customParser(ReadOptions options);
 
-  /** TODO: Research this method to provide a good comment */
-  default boolean compare(int rowNumber, Column<?> temp, Column<?> original) {
-    Object o1 = original.get(rowNumber);
-    Object o2 = temp.get(temp.size() - 1);
-    return o1 == null ? o2 == null : o1.equals(o2);
-  }
-
   /**
    * Returns true if the value at the specified index in column1 is equal to the value at the
    * specified index in column 2
+   *
+   * @throws {@code IndexOutOfBoundsException} if either index exceeds the corresponding column size
    */
   default boolean compare(int col1Row, Column<?> col1, int col2Row, Column<?> col2) {
     Object o1 = col1.get(col1Row);
diff --git a/core/src/main/java/tech/tablesaw/api/Table.java b/core/src/main/java/tech/tablesaw/api/Table.java
index f507749c..5cd84250 100644
--- a/core/src/main/java/tech/tablesaw/api/Table.java
+++ b/core/src/main/java/tech/tablesaw/api/Table.java
@@ -529,17 +529,22 @@ public class Table extends Relation implements Iterable<Row> {
   }
 
   /**
-   * Returns {@code true} if the row @rowNumber in table1 holds the same data as the row at
-   * rowNumber in table2
+   * Returns {@code true} if the row {@code rowNumber} in {@code table1} holds the same values than
+   * the row at {@code rowNumber} in {@code table2}. Returns {@code false} if the number of columns
+   * is different in the two tables.
+   *
+   * @throws {@code IndexOutOfBoundsException} if {@code rowNumber} exceeds either table number of
+   *     rows
    */
   public static boolean compareRows(int rowNumber, Table table1, Table table2) {
-    int columnCount = table1.columnCount();
-    boolean result;
+    final int columnCount = table1.columnCount();
+    if (columnCount != table2.columnCount()) {
+      return false;
+    }
     for (int columnIndex = 0; columnIndex < columnCount; columnIndex++) {
       ColumnType columnType = table1.column(columnIndex).type();
-      result =
-          columnType.compare(rowNumber, table2.column(columnIndex), table1.column(columnIndex));
-      if (!result) {
+      if (!columnType.compare(
+          rowNumber, table2.column(columnIndex), rowNumber, table1.column(columnIndex))) {
         return false;
       }
     }
diff --git a/docs-src/main/userguide/importing_data.md b/docs-src/main/userguide/importing_data.md
index fa544851..7de9614e 100644
--- a/docs-src/main/userguide/importing_data.md
+++ b/docs-src/main/userguide/importing_data.md
@@ -14,9 +14,10 @@ Tablesaw supports importing and exporting data to and from a variety of data typ
 | :----------------------------- | :----- | :----- |
 | CSV (and other delimited text) | Yes    | Yes    |
 | JSON                           | Yes    | Yes    |
+| JSONL                          | Yes    | Yes    |
 | RDBMS (via JDBC)               | Yes    |        |
 | Fixed Width Text               | Yes    | Yes    |
-| Excel                          | Yes    |        |
+| Excel                          | Yes    | Yes    |
 | HTML                           | Yes    | Yes    |
 
 ## Importing data
@@ -260,4 +261,4 @@ List<Table> tables=  xlsxReader.readMultiple(options);
 
 
 
-TODO
\ No newline at end of file
+TODO
diff --git a/excel/src/main/java/tech/tablesaw/io/xlsx/XlsxWriteOptions.java b/excel/src/main/java/tech/tablesaw/io/xlsx/XlsxWriteOptions.java
new file mode 100644
index 00000000..bf667502
--- /dev/null
+++ b/excel/src/main/java/tech/tablesaw/io/xlsx/XlsxWriteOptions.java
@@ -0,0 +1,57 @@
+package tech.tablesaw.io.xlsx;
+
+import java.io.File;
+import java.io.OutputStream;
+import java.io.Writer;
+import tech.tablesaw.io.Destination;
+import tech.tablesaw.io.WriteOptions;
+
+public class XlsxWriteOptions extends WriteOptions {
+
+  protected XlsxWriteOptions(Builder builder) {
+    super(builder);
+  }
+
+  public static Builder builder(Destination dest) {
+    return new Builder(dest);
+  }
+
+  public static Builder builder(OutputStream dest) {
+    return new Builder(dest);
+  }
+
+  public static Builder builder(Writer dest) {
+    return new Builder(dest);
+  }
+
+  public static Builder builder(File dest) {
+    return new Builder(dest);
+  }
+
+  public static Builder builder(String fileName) {
+    return builder(new File(fileName));
+  }
+
+  public static class Builder extends WriteOptions.Builder {
+
+    protected Builder(Destination dest) {
+      super(dest);
+    }
+
+    protected Builder(OutputStream dest) {
+      super(dest);
+    }
+
+    protected Builder(Writer dest) {
+      super(dest);
+    }
+
+    protected Builder(File dest) {
+      super(dest);
+    }
+
+    public XlsxWriteOptions build() {
+      return new XlsxWriteOptions(this);
+    }
+  }
+}
diff --git a/excel/src/main/java/tech/tablesaw/io/xlsx/XlsxWriter.java b/excel/src/main/java/tech/tablesaw/io/xlsx/XlsxWriter.java
new file mode 100644
index 00000000..330dded2
--- /dev/null
+++ b/excel/src/main/java/tech/tablesaw/io/xlsx/XlsxWriter.java
@@ -0,0 +1,105 @@
+package tech.tablesaw.io.xlsx;
+
+import java.io.IOException;
+import java.io.OutputStream;
+import java.time.ZoneId;
+import java.time.ZonedDateTime;
+import java.util.GregorianCalendar;
+import java.util.List;
+import org.apache.poi.ss.usermodel.CellStyle;
+import org.apache.poi.ss.usermodel.DateUtil;
+import org.apache.poi.xssf.usermodel.XSSFSheet;
+import org.apache.poi.xssf.usermodel.XSSFWorkbook;
+import tech.tablesaw.api.ColumnType;
+import tech.tablesaw.api.Table;
+import tech.tablesaw.io.DataWriter;
+import tech.tablesaw.io.Destination;
+import tech.tablesaw.io.RuntimeIOException;
+import tech.tablesaw.io.WriterRegistry;
+
+public class XlsxWriter implements DataWriter<XlsxWriteOptions> {
+
+  private static final XlsxWriter INSTANCE = new XlsxWriter();
+
+  static {
+    register(Table.defaultWriterRegistry);
+  }
+
+  public static void register(WriterRegistry registry) {
+    registry.registerExtension("xlsx", INSTANCE);
+    registry.registerOptions(XlsxWriteOptions.class, INSTANCE);
+  }
+
+  @Override
+  public void write(Table table, Destination dest) {
+    write(table, XlsxWriteOptions.builder(dest).build());
+  }
+
+  @Override
+  public void write(Table table, XlsxWriteOptions options) {
+    try (XSSFWorkbook workbook = new XSSFWorkbook()) {
+      CellStyle localDateStyle = workbook.createCellStyle();
+      localDateStyle.setDataFormat(workbook.createDataFormat().getFormat("yyyy-MM-dd"));
+      CellStyle localDateTimeStyle = workbook.createCellStyle();
+      localDateTimeStyle.setDataFormat(
+          workbook.createDataFormat().getFormat("yyyy-MM-dd hh:mm:ss"));
+      CellStyle localTimeStyle = workbook.createCellStyle();
+      localTimeStyle.setDataFormat(workbook.createDataFormat().getFormat("[h]:mm:ss"));
+      XSSFSheet sheet = workbook.createSheet(table.name());
+      int rowNum = 0;
+      List<String> columnNames = table.columnNames();
+
+      var headerRow = sheet.createRow(rowNum++);
+      int colNum = 0;
+      for (String colName : columnNames) {
+        var cell = headerRow.createCell(colNum++);
+        cell.setCellValue(colName);
+      }
+
+      for (var row : table) {
+        var excelRow = sheet.createRow(rowNum++);
+        colNum = 0;
+        for (String colName : columnNames) {
+          var cell = excelRow.createCell(colNum++);
+          var type = row.getColumnType(colName);
+
+          if (ColumnType.STRING.equals(type)) {
+            cell.setCellValue(row.getString(colName));
+          } else if (ColumnType.LOCAL_DATE.equals(type)) {
+            cell.setCellValue(row.getDate(colName));
+            cell.setCellStyle(localDateStyle);
+          } else if (ColumnType.LOCAL_DATE_TIME.equals(type)) {
+            cell.setCellValue(row.getDate(colName));
+            cell.setCellStyle(localDateTimeStyle);
+          } else if (ColumnType.LOCAL_TIME.equals(type)) {
+            double time = DateUtil.convertTime(row.getTime(colName).toString());
+            cell.setCellValue(time);
+            cell.setCellStyle(localTimeStyle);
+          } else if (ColumnType.INSTANT.equals(type)) {
+            ZonedDateTime zdt =
+                ZonedDateTime.ofInstant(row.getInstant(colName), ZoneId.systemDefault());
+            cell.setCellValue(GregorianCalendar.from(zdt));
+            cell.setCellStyle(localDateTimeStyle);
+          } else if (ColumnType.FLOAT.equals(type)) {
+            cell.setCellValue(row.getFloat(colName));
+          } else if (ColumnType.INTEGER.equals(type)) {
+            cell.setCellValue(row.getInt(colName));
+          } else if (ColumnType.DOUBLE.equals(type)) {
+            cell.setCellValue(row.getDouble(colName));
+          } else if (ColumnType.BOOLEAN.equals(type)) {
+            cell.setCellValue(row.getBoolean(colName));
+          } else {
+            cell.setCellValue(String.valueOf(row.getObject(colName)));
+          }
+        }
+      }
+
+      try (OutputStream os = options.destination().stream()) {
+        workbook.write(os);
+      }
+
+    } catch (IOException e) {
+      throw new RuntimeIOException(e);
+    }
+  }
+}
diff --git a/json/src/main/java/tech/tablesaw/io/jsonl/JsonlReadOptions.java b/json/src/main/java/tech/tablesaw/io/jsonl/JsonlReadOptions.java
new file mode 100644
index 00000000..c3dfd7f2
--- /dev/null
+++ b/json/src/main/java/tech/tablesaw/io/jsonl/JsonlReadOptions.java
@@ -0,0 +1,184 @@
+package tech.tablesaw.io.jsonl;
+
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.Reader;
+import java.io.StringReader;
+import java.net.URL;
+import java.time.format.DateTimeFormatter;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Optional;
+import java.util.function.Function;
+import tech.tablesaw.api.ColumnType;
+import tech.tablesaw.io.ReadOptions;
+import tech.tablesaw.io.Source;
+
+public class JsonlReadOptions extends ReadOptions {
+
+  private final String path;
+
+  protected JsonlReadOptions(Builder builder) {
+    super(builder);
+    this.path = builder.path;
+  }
+
+  public static Builder builder(Source source) {
+    return new Builder(source);
+  }
+
+  public static Builder builder(File file) {
+    return new Builder(file).tableName(file.getName());
+  }
+
+  public static Builder builder(String fileName) {
+    return new Builder(new File(fileName));
+  }
+
+  public static Builder builder(URL url) throws IOException {
+    return new Builder(url);
+  }
+
+  public static Builder builderFromFile(String fileName) {
+    return new Builder(new File(fileName));
+  }
+
+  public static Builder builderFromString(String contents) {
+    return new Builder(new StringReader(contents));
+  }
+
+  public static Builder builderFromUrl(String url) throws IOException {
+    return new Builder(new URL(url));
+  }
+
+  public static Builder builder(InputStream stream) {
+    return new Builder(stream);
+  }
+
+  public static Builder builder(Reader reader) {
+    return new Builder(reader);
+  }
+
+  public String path() {
+    return path;
+  }
+
+  public static class Builder extends ReadOptions.Builder {
+
+    private String path;
+
+    protected Builder(Source source) {
+      super(source);
+    }
+
+    protected Builder(URL url) throws IOException {
+      super(url);
+    }
+
+    public Builder(File file) {
+      super(file);
+    }
+
+    protected Builder(Reader reader) {
+      super(reader);
+    }
+
+    protected Builder(InputStream stream) {
+      super(stream);
+    }
+
+    @Override
+    public JsonlReadOptions build() {
+      return new JsonlReadOptions(this);
+    }
+
+    // Override super-class setters to return an instance of this class
+
+    @Override
+    public Builder header(boolean header) {
+      super.header(header);
+      return this;
+    }
+
+    @Override
+    public Builder tableName(String tableName) {
+      super.tableName(tableName);
+      return this;
+    }
+
+    @Override
+    public Builder sample(boolean sample) {
+      super.sample(sample);
+      return this;
+    }
+
+    @Override
+    public Builder dateFormat(DateTimeFormatter dateFormat) {
+      super.dateFormat(dateFormat);
+      return this;
+    }
+
+    @Override
+    public Builder timeFormat(DateTimeFormatter timeFormat) {
+      super.timeFormat(timeFormat);
+      return this;
+    }
+
+    @Override
+    public Builder dateTimeFormat(DateTimeFormatter dateTimeFormat) {
+      super.dateTimeFormat(dateTimeFormat);
+      return this;
+    }
+
+    @Override
+    public Builder locale(Locale locale) {
+      super.locale(locale);
+      return this;
+    }
+
+    @Override
+    public Builder missingValueIndicator(String... missingValueIndicators) {
+      super.missingValueIndicator(missingValueIndicators);
+      return this;
+    }
+
+    @Override
+    public Builder minimizeColumnSizes() {
+      super.minimizeColumnSizes();
+      return this;
+    }
+
+    /**
+     * @param path the JSON Pointer path used to select a sub-tree in the main document
+     */
+    public Builder path(String path) {
+      this.path = path;
+      return this;
+    }
+
+    @Override
+    public Builder columnTypes(ColumnType[] columnTypes) {
+      super.columnTypes(columnTypes);
+      return this;
+    }
+
+    @Override
+    public Builder columnTypes(Function<String, ColumnType> columnTypeFunction) {
+      super.columnTypes(columnTypeFunction);
+      return this;
+    }
+
+    @Override
+    public Builder columnTypesPartial(Function<String, Optional<ColumnType>> columnTypeFunction) {
+      super.columnTypesPartial(columnTypeFunction);
+      return this;
+    }
+
+    @Override
+    public Builder columnTypesPartial(Map<String, ColumnType> columnTypeByName) {
+      super.columnTypesPartial(columnTypeByName);
+      return this;
+    }
+  }
+}
diff --git a/json/src/main/java/tech/tablesaw/io/jsonl/JsonlReader.java b/json/src/main/java/tech/tablesaw/io/jsonl/JsonlReader.java
new file mode 100644
index 00000000..229cb6b8
--- /dev/null
+++ b/json/src/main/java/tech/tablesaw/io/jsonl/JsonlReader.java
@@ -0,0 +1,127 @@
+package tech.tablesaw.io.jsonl;
+
+import com.fasterxml.jackson.core.JsonParser;
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.ObjectReader;
+import com.github.wnameless.json.flattener.JsonFlattener;
+import java.io.IOException;
+import java.io.Reader;
+import java.util.ArrayList;
+import java.util.Iterator;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Set;
+import tech.tablesaw.api.Table;
+import tech.tablesaw.io.DataReader;
+import tech.tablesaw.io.ReadOptions;
+import tech.tablesaw.io.ReaderRegistry;
+import tech.tablesaw.io.RuntimeIOException;
+import tech.tablesaw.io.Source;
+import tech.tablesaw.io.TableBuildingUtils;
+
+public class JsonlReader implements DataReader<JsonlReadOptions> {
+
+  private static final JsonlReader INSTANCE = new JsonlReader();
+  private static final ObjectMapper mapper = new ObjectMapper();
+
+  static {
+    register(Table.defaultReaderRegistry);
+  }
+
+  public static void register(ReaderRegistry registry) {
+    // no standard exists yet. taken from https://murex.rocks/types/jsonl.html#more-information
+    registry.registerExtension("jsonl", INSTANCE);
+    registry.registerMimeType("application/json-lines", INSTANCE);
+    registry.registerMimeType("application/jsonl", INSTANCE);
+    registry.registerMimeType("application/jsonlines", INSTANCE);
+    registry.registerMimeType("application/ldjson", INSTANCE);
+    registry.registerMimeType("application/ndjson", INSTANCE);
+    registry.registerMimeType("application/x-json-lines", INSTANCE);
+    registry.registerMimeType("application/x-jsonl", INSTANCE);
+    registry.registerMimeType("application/x-jsonlines", INSTANCE);
+    registry.registerMimeType("application/x-ldjson", INSTANCE);
+    registry.registerMimeType("application/x-ndjson", INSTANCE);
+    registry.registerMimeType("text/json-lines", INSTANCE);
+    registry.registerMimeType("text/jsonl", INSTANCE);
+    registry.registerMimeType("text/jsonlines", INSTANCE);
+    registry.registerMimeType("text/ldjson", INSTANCE);
+    registry.registerMimeType("text/ndjson", INSTANCE);
+    registry.registerMimeType("text/x-json-lines", INSTANCE);
+    registry.registerMimeType("text/x-jsonl", INSTANCE);
+    registry.registerMimeType("text/x-jsonlines", INSTANCE);
+    registry.registerMimeType("text/x-ldjson", INSTANCE);
+    registry.registerMimeType("text/x-ndjson", INSTANCE);
+    registry.registerOptions(JsonlReadOptions.class, INSTANCE);
+  }
+
+  @Override
+  public Table read(JsonlReadOptions options) {
+    ObjectReader stream = mapper.readerFor(JsonNode.class);
+    try {
+      Reader reader = options.source().createReader(null);
+      JsonParser parser = stream.createParser(reader);
+      Iterator<JsonNode> iter = stream.readValues(parser);
+      return convertObjects(iter, options);
+    } catch (IOException e) {
+      throw new RuntimeIOException(e);
+    }
+  }
+
+  private Table convertObjects(Iterator<JsonNode> iter, ReadOptions options) {
+    // flatten each object inside the array
+    StringBuilder result = new StringBuilder("[");
+    boolean first = true;
+    for (; iter.hasNext(); ) {
+      JsonNode rowObj = iter.next();
+      String flattenedRow = null;
+      try {
+        flattenedRow = JsonFlattener.flatten(mapper.writeValueAsString(rowObj));
+      } catch (JsonProcessingException e) {
+        throw new RuntimeIOException(e);
+      }
+      if (!first) {
+        result.append(",");
+      }
+      first = false;
+      result.append(flattenedRow);
+    }
+    String flattenedJsonString = result.append("]").toString();
+    JsonNode flattenedJsonObj = null;
+    try {
+      flattenedJsonObj = mapper.readTree(flattenedJsonString);
+    } catch (JsonProcessingException e) {
+      throw new RuntimeIOException(e);
+    }
+
+    Set<String> colNames = new LinkedHashSet<>();
+    for (JsonNode row : flattenedJsonObj) {
+      Iterator<String> fieldNames = row.fieldNames();
+      while (fieldNames.hasNext()) {
+        colNames.add(fieldNames.next());
+      }
+    }
+
+    List<String> columnNames = new ArrayList<>(colNames);
+    List<String[]> dataRows = new ArrayList<>();
+    for (JsonNode node : flattenedJsonObj) {
+      String[] arr = new String[columnNames.size()];
+      for (int i = 0; i < columnNames.size(); i++) {
+        if (node.has(columnNames.get(i))) {
+          arr[i] = node.get(columnNames.get(i)).asText();
+        } else {
+          arr[i] = null;
+        }
+      }
+      dataRows.add(arr);
+    }
+
+    return TableBuildingUtils.build(columnNames, dataRows, options);
+  }
+
+  @Override
+  public Table read(Source source) {
+    return read(JsonlReadOptions.builder(source).build());
+  }
+}
diff --git a/json/src/main/java/tech/tablesaw/io/jsonl/JsonlWriteOptions.java b/json/src/main/java/tech/tablesaw/io/jsonl/JsonlWriteOptions.java
new file mode 100644
index 00000000..aacf905d
--- /dev/null
+++ b/json/src/main/java/tech/tablesaw/io/jsonl/JsonlWriteOptions.java
@@ -0,0 +1,31 @@
+package tech.tablesaw.io.jsonl;
+
+import java.io.Writer;
+import tech.tablesaw.io.Destination;
+import tech.tablesaw.io.WriteOptions;
+
+public class JsonlWriteOptions extends WriteOptions {
+
+  private JsonlWriteOptions(Builder builder) {
+    super(builder);
+  }
+
+  public static Builder builder(Writer writer) {
+    return new Builder(new Destination(writer));
+  }
+
+  public static Builder builder(Destination destination) {
+    return new Builder(destination);
+  }
+
+  public static class Builder extends WriteOptions.Builder {
+
+    protected Builder(Destination destination) {
+      super(destination);
+    }
+
+    public JsonlWriteOptions build() {
+      return new JsonlWriteOptions(this);
+    }
+  }
+}
diff --git a/json/src/main/java/tech/tablesaw/io/jsonl/JsonlWriter.java b/json/src/main/java/tech/tablesaw/io/jsonl/JsonlWriter.java
new file mode 100644
index 00000000..aaa71edd
--- /dev/null
+++ b/json/src/main/java/tech/tablesaw/io/jsonl/JsonlWriter.java
@@ -0,0 +1,66 @@
+/*
+ * 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.io.jsonl;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
+import java.io.IOException;
+import java.io.Writer;
+import tech.tablesaw.api.Table;
+import tech.tablesaw.io.DataWriter;
+import tech.tablesaw.io.Destination;
+import tech.tablesaw.io.RuntimeIOException;
+import tech.tablesaw.io.WriterRegistry;
+
+public class JsonlWriter implements DataWriter<JsonlWriteOptions> {
+
+  private static final JsonlWriter INSTANCE = new JsonlWriter();
+  private static final ObjectMapper mapper =
+      new ObjectMapper().registerModule(new JavaTimeModule());
+
+  static {
+    register(Table.defaultWriterRegistry);
+  }
+
+  public static void register(WriterRegistry registry) {
+    registry.registerExtension("jsonl", INSTANCE);
+    registry.registerOptions(JsonlWriteOptions.class, INSTANCE);
+  }
+
+  public void write(Table table, JsonlWriteOptions options) {
+    try (Writer writer = options.destination().createWriter()) {
+      for (int r = 0; r < table.rowCount(); r++) {
+        ObjectNode row = mapper.createObjectNode();
+        for (int c = 0; c < table.columnCount(); c++) {
+          row.set(table.column(c).name(), mapper.convertValue(table.get(r, c), JsonNode.class));
+        }
+        String str = mapper.writeValueAsString(row);
+        writer.write(str);
+        if (r < table.rowCount() - 1) {
+          writer.write("\n");
+        }
+      }
+    } catch (IOException e) {
+      throw new RuntimeIOException(e);
+    }
+  }
+
+  @Override
+  public void write(Table table, Destination dest) {
+    write(table, JsonlWriteOptions.builder(dest).build());
+  }
+}


Test patch:
diff --git a/core/src/test/java/tech/tablesaw/aggregate/AggregateFunctionsTest.java b/core/src/test/java/tech/tablesaw/aggregate/AggregateFunctionsTest.java
index 19233f77..399808df 100644
--- a/core/src/test/java/tech/tablesaw/aggregate/AggregateFunctionsTest.java
+++ b/core/src/test/java/tech/tablesaw/aggregate/AggregateFunctionsTest.java
@@ -323,7 +323,13 @@ class AggregateFunctionsTest {
     StringColumn stringColumn = StringColumn.create("s", strings);
 
     Table table = Table.create("test", booleanColumn, numberColumn);
-    table.summarize(booleanColumn, numberColumn, countTrue, stdDev).by(stringColumn);
+    Table summary =
+        table.summarize(booleanColumn, numberColumn, countTrue, stdDev).by(stringColumn);
+
+    assertEquals(2.0, summary.doubleColumn(1).get(0), 1e-6);
+    assertEquals(0.0, summary.doubleColumn(1).get(1), 1e-6);
+    assertEquals(Math.sqrt(2), summary.doubleColumn(2).get(0), 1e-6);
+    assertEquals(Math.sqrt(2), summary.doubleColumn(2).get(1), 1e-6);
   }
 
   @Test
diff --git a/core/src/test/java/tech/tablesaw/api/TableTest.java b/core/src/test/java/tech/tablesaw/api/TableTest.java
index d659a0ea..51c8b90e 100644
--- a/core/src/test/java/tech/tablesaw/api/TableTest.java
+++ b/core/src/test/java/tech/tablesaw/api/TableTest.java
@@ -50,6 +50,7 @@ public class TableTest {
   private static final ColumnType[] BUSH_COLUMN_TYPES = {LOCAL_DATE, INTEGER, STRING};
   private static Table bush;
   private static Table bushMinimized;
+  private static Table missingValues;
 
   private Table table;
   private final DoubleColumn f1 = DoubleColumn.create("f1");
@@ -64,6 +65,8 @@ public class TableTest {
                     .columnTypes(BUSH_COLUMN_TYPES));
     ColumnType[] types = {LOCAL_DATE, SHORT, STRING};
     bushMinimized = Table.read().csv(CsvReadOptions.builder("../data/bush.csv").columnTypes(types));
+    missingValues = Table.read().csv(CsvReadOptions.builder("../data/missing_values.csv")
+      .missingValueIndicator("-"));
   }
 
   @BeforeEach
@@ -916,4 +919,36 @@ public class TableTest {
       fail("toString shouldn't throw " + e);
     }
   }
+
+  @Test
+  void testCompareRowsIdentical() {
+      for(int i = 0; i < missingValues.rowCount(); i++) {
+          assertTrue(Table.compareRows(i, missingValues, missingValues), "Row " + i + " is not equal to itself");
+      }
+  }
+
+  @Test
+  void testCompareRowsDifferent() {
+      Table differentTable = missingValues.copy().sortDescendingOn("Sales");
+      for(int i = 0; i < missingValues.rowCount(); i++) {
+          assertFalse(Table.compareRows(i, missingValues, differentTable), "Row " + i + " is equal to a different row");
+      }
+  }
+
+  @Test
+  void testCompareRowsDifferentColumns() {
+      Table differentTable = missingValues.copy().removeColumns("Sales");
+      for(int i = 0; i < missingValues.rowCount(); i++) {
+          assertFalse(Table.compareRows(i, missingValues, differentTable), "Row " + i + " is equal to a row with less columns");
+      }
+  }
+  
+  @Test
+  void testCompareRowsOutOfBound() {
+      Table differentTable = missingValues.copy().dropRows(0);
+      int lastRowNumber = missingValues.rowCount() - 1;
+      assertThrows(IndexOutOfBoundsException.class,
+          () -> Table.compareRows(lastRowNumber, missingValues, differentTable),
+          "Row outside range does not throw exception");
+  }
 }
diff --git a/core/src/test/java/tech/tablesaw/joining/DataFrameJoinerTest.java b/core/src/test/java/tech/tablesaw/joining/DataFrameJoinerTest.java
index 2fca53b1..64eddbad 100644
--- a/core/src/test/java/tech/tablesaw/joining/DataFrameJoinerTest.java
+++ b/core/src/test/java/tech/tablesaw/joining/DataFrameJoinerTest.java
@@ -2006,7 +2006,7 @@ public class DataFrameJoinerTest {
   @Test
   public void leftOuterEmptyLeftTable() {
     Table leftTable = Table.create(StringColumn.create("Animal"));
-    Table joined = leftTable.joinOn("Animal").with(ANIMAL_NAMES).join();
+    Table joined = leftTable.joinOn("Animal").with(ANIMAL_NAMES).type(LEFT_OUTER).join();
     assertEquals(0, joined.rowCount());
     for (Column<?> column : joined.columnArray()) {
       assertEquals(0, column.size());
diff --git a/core/src/test/java/tech/tablesaw/util/DoubleArraysTest.java b/core/src/test/java/tech/tablesaw/util/DoubleArraysTest.java
index 6a6e7fdc..9e91a336 100644
--- a/core/src/test/java/tech/tablesaw/util/DoubleArraysTest.java
+++ b/core/src/test/java/tech/tablesaw/util/DoubleArraysTest.java
@@ -14,6 +14,7 @@
 
 package tech.tablesaw.util;
 
+import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 
 import java.util.Arrays;
@@ -27,8 +28,9 @@ public class DoubleArraysTest {
   public void testTo2dArray() throws Exception {
     Table table = Table.read().csv("../data/tornadoes_1950-2014.csv");
     TableSliceGroup tableSliceGroup = table.splitOn("Scale");
-    int columnNuumber = table.columnIndex("Injuries");
-    DoubleArrays.to2dArray(tableSliceGroup, columnNuumber);
+    int columnNumber = table.columnIndex("Injuries");
+    double[][] a2dArray = DoubleArrays.to2dArray(tableSliceGroup, columnNumber);
+    assertEquals(7, a2dArray.length);
   }
 
   @Test
diff --git a/excel/src/test/java/tech/tablesaw/io/xlsx/XlsxWriterTest.java b/excel/src/test/java/tech/tablesaw/io/xlsx/XlsxWriterTest.java
new file mode 100644
index 00000000..95ca71ad
--- /dev/null
+++ b/excel/src/test/java/tech/tablesaw/io/xlsx/XlsxWriterTest.java
@@ -0,0 +1,52 @@
+package tech.tablesaw.io.xlsx;
+
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import java.io.File;
+import java.io.IOException;
+import java.time.LocalDate;
+import org.junit.jupiter.api.Test;
+import tech.tablesaw.api.*;
+
+public class XlsxWriterTest {
+
+  @Test
+  public void testXlsxWriter() throws IOException {
+    Table table =
+        Table.create("Employees")
+            .addColumns(
+                IntColumn.create("id", 1, 2, 3),
+                DateColumn.create(
+                    "start_date", toLocalDates("2020-01-10", "2021-12-01", "2023-04-20")),
+                DoubleColumn.create("salary", 6723.5, 4879, 5512.8));
+    File file = File.createTempFile(table.name(), ".xlsx");
+    table.write().usingOptions(XlsxWriteOptions.builder(file).build());
+
+    Table table2 =
+        Table.read()
+            .usingOptions(
+                XlsxReadOptions.builder(file)
+                    .columnTypes(
+                        new ColumnType[] {ColumnType.INTEGER, ColumnType.STRING, ColumnType.DOUBLE})
+                    .build());
+    assertEquals(3, table2.rowCount(), "Number of rows");
+    assertEquals(3, table2.columnCount(), "Number of columns");
+    assertArrayEquals(
+        new String[] {"id", "start_date", "salary"},
+        table2.columnNames().toArray(new String[] {}),
+        "Column names");
+    assertEquals(1, table2.get(0, 0), "First row. first column");
+    assertEquals("2021-12-01", table2.get(1, 1), "Second row, second column");
+    assertEquals(5512.8, table2.get(2, 2), "Third row, third column");
+    file.deleteOnExit();
+  }
+
+  LocalDate[] toLocalDates(String... dates) {
+    LocalDate[] d = new LocalDate[dates.length];
+    for (int i = 0; i < dates.length; i++) {
+      d[i] = LocalDate.parse(dates[i]);
+    }
+    return d;
+  }
+}
diff --git a/json/src/test/java/tech/tablesaw/io/jsonl/JsonlReaderTest.java b/json/src/test/java/tech/tablesaw/io/jsonl/JsonlReaderTest.java
new file mode 100644
index 00000000..b8085958
--- /dev/null
+++ b/json/src/test/java/tech/tablesaw/io/jsonl/JsonlReaderTest.java
@@ -0,0 +1,58 @@
+/*
+ * 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.io.jsonl;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import org.junit.jupiter.api.Test;
+import tech.tablesaw.api.ColumnType;
+import tech.tablesaw.api.IntColumn;
+import tech.tablesaw.api.Table;
+
+public class JsonlReaderTest {
+
+  @Test
+  public void arrayOfNestedObjects() {
+    String json =
+        "{\"a\":1453438800000,\"b\":{\"c\":-2.1448117025014}}\n" //
+            + "{\"a\":1454043600000,\"b\":{\"c\":-2.9763153817574}}\n" //
+            + "{\"a\":1454648400000,\"b\":{\"c\":-2.9545283436391}}";
+    Table table = Table.read().string(json, "jsonl");
+    assertEquals(2, table.columnCount());
+    assertEquals(3, table.rowCount());
+    assertEquals("a", table.column(0).name());
+    assertEquals("b.c", table.column(1).name());
+    assertEquals(ColumnType.LONG, table.typeArray()[0]);
+  }
+
+  @Test
+  public void arrayOfRowsWithIncompleteIndexes() {
+    String json =
+        "{\"A\" : \"123\", \"B\" : \"456\"}\n" //
+            + "{\"B\" : \"789\", \"C\" : \"123\"}";
+
+    Table expected =
+        Table.create(
+            IntColumn.create("A", new int[] {123, Integer.MIN_VALUE}),
+            IntColumn.create("B", new int[] {456, 789}),
+            IntColumn.create("C", new int[] {Integer.MIN_VALUE, 123}));
+    Table actual = Table.read().string(json, "jsonl");
+
+    assertEquals(ColumnType.INTEGER, actual.typeArray()[0]);
+    assertEquals(expected.column("A").asList(), actual.column("A").asList());
+    assertEquals(expected.column("B").asList(), actual.column("B").asList());
+    assertEquals(expected.column("C").asList(), actual.column("C").asList());
+  }
+}
diff --git a/json/src/test/java/tech/tablesaw/io/jsonl/JsonlWriterTest.java b/json/src/test/java/tech/tablesaw/io/jsonl/JsonlWriterTest.java
new file mode 100644
index 00000000..d5dccf40
--- /dev/null
+++ b/json/src/test/java/tech/tablesaw/io/jsonl/JsonlWriterTest.java
@@ -0,0 +1,32 @@
+/*
+ * 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.io.jsonl;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import org.junit.jupiter.api.Test;
+import tech.tablesaw.api.Table;
+
+public class JsonlWriterTest {
+
+  @Test
+  public void arrayOfObjects() {
+    String json =
+        "{\"a\":1453438800000,\"b\":-2.144}\n{\"a\":1454043600000,\"b\":-2.976}\n{\"a\":1454648400000,\"b\":-2.954}";
+    Table table = Table.read().string(json, "jsonl");
+    String output = table.write().toString("jsonl");
+    assertEquals(json, output);
+  }
+}