You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@calcite.apache.org by jh...@apache.org on 2020/01/06 08:12:05 UTC

[calcite] 07/07: [CALCITE-3526] SqlPrettyWriter should have options to fold/chop long lines, print leading commas

This is an automated email from the ASF dual-hosted git repository.

jhyde pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/calcite.git

commit c416c31fc376868bdd672afd84ec06dc75d56575
Author: Julian Hyde <jh...@apache.org>
AuthorDate: Mon Oct 28 13:02:01 2019 -0700

    [CALCITE-3526] SqlPrettyWriter should have options to fold/chop long lines, print leading commas
    
    Move SqlPrettyWriter.Config to top-level class SqlWriterConfig, and
    make it an interface implemented via ImmutableBeans; fixes
      [CALCITE-1585] SqlPrettyWriter doesn't respect alwaysUseParentheses
    because config is now held in an immutable object rather than the
    SqlPrettyWriter.
    
    Add option SqlWriterConfig.withLeadingComma, to generate
    
      SELECT x
          , y
      FROM t
    
    rather than
    
      SELECT x,
          y
      FROM t
    
    Add option SqlWriterConfig.fromLength, which controls when lines are
    considered long enough to chop or fold. Unlike lineLength(), which is
    absolute, it is relative to the current sub-query.
    
    Refactor: add SqlNodeList.SINGLETON_STAR.
    
    Add lots of options including selectFolding(), fromFolding(),
    clauseEndsList().
    
    Extend SqlParserTest to test randomly-configured SqlPrettyWriter.
---
 .../org/apache/calcite/test/BabelQuidemTest.java   |    4 +-
 babel/src/test/resources/sql/select.iq             |    2 +-
 .../calcite/adapter/cassandra/CassandraSchema.java |   13 +-
 .../org/apache/calcite/adapter/jdbc/JdbcTable.java |   12 +-
 .../calcite/rel/rel2sql/RelToSqlConverter.java     |    3 +-
 .../java/org/apache/calcite/sql/SqlAsOperator.java |    2 +-
 .../main/java/org/apache/calcite/sql/SqlJoin.java  |   20 +-
 .../org/apache/calcite/sql/SqlMatchRecognize.java  |    6 +-
 .../main/java/org/apache/calcite/sql/SqlNode.java  |   44 +-
 .../java/org/apache/calcite/sql/SqlNodeList.java   |   57 +-
 .../java/org/apache/calcite/sql/SqlOperator.java   |   36 +-
 .../java/org/apache/calcite/sql/SqlOrderBy.java    |    6 +-
 .../org/apache/calcite/sql/SqlSelectOperator.java  |   65 +-
 .../apache/calcite/sql/SqlWithinGroupOperator.java |    2 +-
 .../java/org/apache/calcite/sql/SqlWriter.java     |   55 +-
 .../org/apache/calcite/sql/SqlWriterConfig.java    |  434 +++++++
 .../apache/calcite/sql/fun/SqlCaseOperator.java    |    5 +-
 .../calcite/sql/fun/SqlStdOperatorTable.java       |    1 +
 .../apache/calcite/sql/pretty/SqlPrettyWriter.java | 1231 ++++++++++++--------
 .../apache/calcite/sql/type/IntervalSqlType.java   |   11 +-
 .../apache/calcite/sql/parser/SqlParserTest.java   |  168 ++-
 .../calcite/sql/test/SqlOperatorBaseTest.java      |    4 +-
 .../calcite/sql/test/SqlPrettyWriterTest.java      |  262 ++++-
 .../org/apache/calcite/test/JdbcAdapterTest.java   |    2 +-
 .../calcite/sql/test/SqlPrettyWriterTest.xml       |  337 +++++-
 .../org/apache/calcite/piglet/PigConverter.java    |   13 +-
 .../java/org/apache/calcite/test/PigRelOpTest.java |  122 +-
 .../org/apache/calcite/sql/ddl/SqlDdlNodes.java    |   22 +-
 28 files changed, 2106 insertions(+), 833 deletions(-)

diff --git a/babel/src/test/java/org/apache/calcite/test/BabelQuidemTest.java b/babel/src/test/java/org/apache/calcite/test/BabelQuidemTest.java
index bc82d34..51477db 100644
--- a/babel/src/test/java/org/apache/calcite/test/BabelQuidemTest.java
+++ b/babel/src/test/java/org/apache/calcite/test/BabelQuidemTest.java
@@ -23,7 +23,6 @@ import org.apache.calcite.plan.Contexts;
 import org.apache.calcite.schema.SchemaPlus;
 import org.apache.calcite.sql.SqlNode;
 import org.apache.calcite.sql.SqlWriter;
-import org.apache.calcite.sql.dialect.CalciteSqlDialect;
 import org.apache.calcite.sql.parser.SqlParser;
 import org.apache.calcite.sql.parser.babel.SqlBabelParserImpl;
 import org.apache.calcite.sql.pretty.SqlPrettyWriter;
@@ -152,8 +151,7 @@ public class BabelQuidemTest extends QuidemTest {
         final Planner planner = Frameworks.getPlanner(config.build());
         final SqlNode node = planner.parse(sqlCommand.sql);
         final SqlNode validateNode = planner.validate(node);
-        final SqlWriter sqlWriter =
-            new SqlPrettyWriter(CalciteSqlDialect.DEFAULT);
+        final SqlWriter sqlWriter = new SqlPrettyWriter();
         validateNode.unparse(sqlWriter, 0, 0);
         x.echo(ImmutableList.of(sqlWriter.toSqlString().getSql()));
       } else {
diff --git a/babel/src/test/resources/sql/select.iq b/babel/src/test/resources/sql/select.iq
index 4e9f822..07399eb 100755
--- a/babel/src/test/resources/sql/select.iq
+++ b/babel/src/test/resources/sql/select.iq
@@ -25,7 +25,7 @@ ORDER BY emp.deptno;
 
 SELECT "EMP"."ENAME"
 FROM "scott"."EMP" AS "EMP",
-        "scott"."DEPT" AS "DEPT"
+    "scott"."DEPT" AS "DEPT"
 ORDER BY "EMP"."DEPTNO"
 !explain-validated-on all
 
diff --git a/cassandra/src/main/java/org/apache/calcite/adapter/cassandra/CassandraSchema.java b/cassandra/src/main/java/org/apache/calcite/adapter/cassandra/CassandraSchema.java
index fd6819c..85c4c6d 100644
--- a/cassandra/src/main/java/org/apache/calcite/adapter/cassandra/CassandraSchema.java
+++ b/cassandra/src/main/java/org/apache/calcite/adapter/cassandra/CassandraSchema.java
@@ -30,7 +30,7 @@ import org.apache.calcite.schema.impl.AbstractSchema;
 import org.apache.calcite.schema.impl.MaterializedViewTable;
 import org.apache.calcite.sql.SqlSelect;
 import org.apache.calcite.sql.SqlWriter;
-import org.apache.calcite.sql.dialect.CalciteSqlDialect;
+import org.apache.calcite.sql.SqlWriterConfig;
 import org.apache.calcite.sql.parser.SqlParseException;
 import org.apache.calcite.sql.parser.SqlParser;
 import org.apache.calcite.sql.pretty.SqlPrettyWriter;
@@ -54,8 +54,6 @@ import com.google.common.collect.ImmutableMap;
 
 import org.slf4j.Logger;
 
-import java.io.PrintWriter;
-import java.io.StringWriter;
 import java.net.InetSocketAddress;
 import java.util.ArrayList;
 import java.util.List;
@@ -290,11 +288,12 @@ public class CassandraSchema extends AbstractSchema {
         continue;
       }
 
-      StringWriter stringWriter = new StringWriter(query.length());
-      PrintWriter printWriter = new PrintWriter(stringWriter);
-      SqlWriter writer = new SqlPrettyWriter(CalciteSqlDialect.DEFAULT, true, printWriter);
+      final StringBuilder buf = new StringBuilder(query.length());
+      final SqlWriterConfig config = SqlPrettyWriter.config()
+          .withAlwaysUseParentheses(true);
+      final SqlWriter writer = new SqlPrettyWriter(config, buf);
       parsedQuery.unparse(writer, 0, 0);
-      query = stringWriter.toString();
+      query = buf.toString();
 
       // Add the view for this query
       String viewName = "$" + getTableNames().size();
diff --git a/core/src/main/java/org/apache/calcite/adapter/jdbc/JdbcTable.java b/core/src/main/java/org/apache/calcite/adapter/jdbc/JdbcTable.java
index b1f32ad..3a8eff5 100644
--- a/core/src/main/java/org/apache/calcite/adapter/jdbc/JdbcTable.java
+++ b/core/src/main/java/org/apache/calcite/adapter/jdbc/JdbcTable.java
@@ -47,6 +47,7 @@ import org.apache.calcite.schema.impl.AbstractTableQueryable;
 import org.apache.calcite.sql.SqlIdentifier;
 import org.apache.calcite.sql.SqlNodeList;
 import org.apache.calcite.sql.SqlSelect;
+import org.apache.calcite.sql.SqlWriterConfig;
 import org.apache.calcite.sql.parser.SqlParserPos;
 import org.apache.calcite.sql.pretty.SqlPrettyWriter;
 import org.apache.calcite.sql.util.SqlString;
@@ -58,7 +59,6 @@ import com.google.common.collect.Lists;
 import java.sql.SQLException;
 import java.util.ArrayList;
 import java.util.Collection;
-import java.util.Collections;
 import java.util.List;
 import java.util.Objects;
 
@@ -141,14 +141,14 @@ public class JdbcTable extends AbstractQueryableTable
   }
 
   SqlString generateSql() {
-    final SqlNodeList selectList =
-        new SqlNodeList(
-            Collections.singletonList(SqlIdentifier.star(SqlParserPos.ZERO)),
-            SqlParserPos.ZERO);
+    final SqlNodeList selectList = SqlNodeList.SINGLETON_STAR;
     SqlSelect node =
         new SqlSelect(SqlParserPos.ZERO, SqlNodeList.EMPTY, selectList,
             tableName(), null, null, null, null, null, null, null, null);
-    final SqlPrettyWriter writer = new SqlPrettyWriter(jdbcSchema.dialect);
+    final SqlWriterConfig config = SqlPrettyWriter.config()
+        .withAlwaysUseParentheses(true)
+        .withDialect(jdbcSchema.dialect);
+    final SqlPrettyWriter writer = new SqlPrettyWriter(config);
     node.unparse(writer, 0, 0);
     return writer.toSqlString();
   }
diff --git a/core/src/main/java/org/apache/calcite/rel/rel2sql/RelToSqlConverter.java b/core/src/main/java/org/apache/calcite/rel/rel2sql/RelToSqlConverter.java
index e41b1ce..d499a1c 100644
--- a/core/src/main/java/org/apache/calcite/rel/rel2sql/RelToSqlConverter.java
+++ b/core/src/main/java/org/apache/calcite/rel/rel2sql/RelToSqlConverter.java
@@ -521,8 +521,7 @@ public class RelToSqlConverter extends SqlImplementor
 
           // Wrap "SELECT 1 AS x"
           // as "SELECT * FROM (SELECT 1 AS x) AS t WHERE false"
-          query = new SqlSelect(POS, null,
-              new SqlNodeList(ImmutableList.of(SqlIdentifier.star(POS)), POS),
+          query = new SqlSelect(POS, null, SqlNodeList.SINGLETON_STAR,
               as(query, "t"), createAlwaysFalseCondition(), null, null,
               null, null, null, null, null);
         } else {
diff --git a/core/src/main/java/org/apache/calcite/sql/SqlAsOperator.java b/core/src/main/java/org/apache/calcite/sql/SqlAsOperator.java
index 5e2b7cf..e90340d 100644
--- a/core/src/main/java/org/apache/calcite/sql/SqlAsOperator.java
+++ b/core/src/main/java/org/apache/calcite/sql/SqlAsOperator.java
@@ -72,7 +72,7 @@ public class SqlAsOperator extends SqlSpecialOperator {
     assert call.operandCount() >= 2;
     final SqlWriter.Frame frame =
         writer.startList(
-            SqlWriter.FrameTypeEnum.SIMPLE);
+            SqlWriter.FrameTypeEnum.AS);
     call.operand(0).unparse(writer, leftPrec, getLeftPrec());
     final boolean needsSpace = true;
     writer.setNeedWhitespace(needsSpace);
diff --git a/core/src/main/java/org/apache/calcite/sql/SqlJoin.java b/core/src/main/java/org/apache/calcite/sql/SqlJoin.java
index 1977aa6..21663e8 100644
--- a/core/src/main/java/org/apache/calcite/sql/SqlJoin.java
+++ b/core/src/main/java/org/apache/calcite/sql/SqlJoin.java
@@ -196,37 +196,32 @@ public class SqlJoin extends SqlCall {
         int rightPrec) {
       final SqlJoin join = (SqlJoin) call;
 
-      final SqlWriter.Frame joinFrame =
-          writer.startList(SqlWriter.FrameTypeEnum.JOIN);
       join.left.unparse(
           writer,
           leftPrec,
           getLeftPrec());
-      String natural = "";
-      if (join.isNatural()) {
-        natural = "NATURAL ";
-      }
       switch (join.getJoinType()) {
       case COMMA:
         writer.sep(",", true);
         break;
       case CROSS:
-        writer.sep(natural + "CROSS JOIN");
+        writer.sep(join.isNatural() ? "NATURAL CROSS JOIN" : "CROSS JOIN");
         break;
       case FULL:
-        writer.sep(natural + "FULL JOIN");
+        writer.sep(join.isNatural() ? "NATURAL FULL JOIN" : "FULL JOIN");
         break;
       case INNER:
-        writer.sep(natural + "INNER JOIN");
+        writer.sep(join.isNatural() ? "NATURAL INNER JOIN" : "INNER JOIN");
         break;
       case LEFT:
-        writer.sep(natural + "LEFT JOIN");
+        writer.sep(join.isNatural() ? "NATURAL LEFT JOIN" : "LEFT JOIN");
         break;
       case LEFT_SEMI_JOIN:
-        writer.sep(natural + "LEFT SEMI JOIN");
+        writer.sep(join.isNatural() ? "NATURAL LEFT SEMI JOIN"
+            : "LEFT SEMI JOIN");
         break;
       case RIGHT:
-        writer.sep(natural + "RIGHT JOIN");
+        writer.sep(join.isNatural() ? "NATURAL RIGHT JOIN" : "RIGHT JOIN");
         break;
       default:
         throw Util.unexpected(join.getJoinType());
@@ -254,7 +249,6 @@ public class SqlJoin extends SqlCall {
           throw Util.unexpected(join.getConditionType());
         }
       }
-      writer.endList(joinFrame);
     }
   }
 }
diff --git a/core/src/main/java/org/apache/calcite/sql/SqlMatchRecognize.java b/core/src/main/java/org/apache/calcite/sql/SqlMatchRecognize.java
index ecbc106..c4c156b 100644
--- a/core/src/main/java/org/apache/calcite/sql/SqlMatchRecognize.java
+++ b/core/src/main/java/org/apache/calcite/sql/SqlMatchRecognize.java
@@ -336,10 +336,8 @@ public class SqlMatchRecognize extends SqlCall {
       if (pattern.orderList != null && pattern.orderList.size() > 0) {
         writer.newlineAndIndent();
         writer.sep("ORDER BY");
-        final SqlWriter.Frame orderFrame =
-            writer.startList(SqlWriter.FrameTypeEnum.ORDER_BY_LIST);
-        unparseListClause(writer, pattern.orderList);
-        writer.endList(orderFrame);
+        writer.list(SqlWriter.FrameTypeEnum.ORDER_BY_LIST, SqlWriter.COMMA,
+            pattern.orderList);
       }
 
       if (pattern.measureList != null && pattern.measureList.size() > 0) {
diff --git a/core/src/main/java/org/apache/calcite/sql/SqlNode.java b/core/src/main/java/org/apache/calcite/sql/SqlNode.java
index ca3434c..9947f78 100644
--- a/core/src/main/java/org/apache/calcite/sql/SqlNode.java
+++ b/core/src/main/java/org/apache/calcite/sql/SqlNode.java
@@ -32,6 +32,7 @@ import java.util.Collection;
 import java.util.List;
 import java.util.Objects;
 import java.util.Set;
+import java.util.function.UnaryOperator;
 import javax.annotation.Nonnull;
 
 /**
@@ -122,7 +123,33 @@ public abstract class SqlNode implements Cloneable {
   }
 
   public String toString() {
-    return toSqlString(null).getSql();
+    return toSqlString(c -> c.withDialect(AnsiSqlDialect.DEFAULT)
+        .withAlwaysUseParentheses(false)
+        .withSelectListItemsOnSeparateLines(false)
+        .withUpdateSetListNewline(false)
+        .withIndentation(0)).getSql();
+  }
+
+  /**
+   * Returns the SQL text of the tree of which this <code>SqlNode</code> is
+   * the root.
+   *
+   * <p>Typical return values are:
+   *
+   * <ul>
+   * <li>'It''s a bird!'
+   * <li>NULL
+   * <li>12.3
+   * <li>DATE '1969-04-29'
+   * </ul>
+   *
+   * @param transform   Transform that sets desired writer configuration
+   */
+  public SqlString toSqlString(UnaryOperator<SqlWriterConfig> transform) {
+    final SqlWriterConfig config = transform.apply(SqlPrettyWriter.config());
+    SqlPrettyWriter writer = new SqlPrettyWriter(config);
+    unparse(writer, 0, 0);
+    return writer.toSqlString();
   }
 
   /**
@@ -143,15 +170,12 @@ public abstract class SqlNode implements Cloneable {
    *                    useful for parse test, but false by default
    */
   public SqlString toSqlString(SqlDialect dialect, boolean forceParens) {
-    if (dialect == null) {
-      dialect = AnsiSqlDialect.DEFAULT;
-    }
-    SqlPrettyWriter writer = new SqlPrettyWriter(dialect);
-    writer.setAlwaysUseParentheses(forceParens);
-    writer.setSelectListItemsOnSeparateLines(false);
-    writer.setIndentation(0);
-    unparse(writer, 0, 0);
-    return writer.toSqlString();
+    return toSqlString(c ->
+        c.withDialect(Util.first(dialect, AnsiSqlDialect.DEFAULT))
+            .withAlwaysUseParentheses(forceParens)
+            .withSelectListItemsOnSeparateLines(false)
+            .withUpdateSetListNewline(false)
+            .withIndentation(0));
   }
 
   public SqlString toSqlString(SqlDialect dialect) {
diff --git a/core/src/main/java/org/apache/calcite/sql/SqlNodeList.java b/core/src/main/java/org/apache/calcite/sql/SqlNodeList.java
index 426869d..d1a5329 100644
--- a/core/src/main/java/org/apache/calcite/sql/SqlNodeList.java
+++ b/core/src/main/java/org/apache/calcite/sql/SqlNodeList.java
@@ -16,13 +16,14 @@
  */
 package org.apache.calcite.sql;
 
-import org.apache.calcite.sql.fun.SqlStdOperatorTable;
 import org.apache.calcite.sql.parser.SqlParserPos;
 import org.apache.calcite.sql.util.SqlVisitor;
 import org.apache.calcite.sql.validate.SqlValidator;
 import org.apache.calcite.sql.validate.SqlValidatorScope;
 import org.apache.calcite.util.Litmus;
 
+import com.google.common.collect.ImmutableList;
+
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Iterator;
@@ -45,6 +46,19 @@ public class SqlNodeList extends SqlNode implements Iterable<SqlNode> {
         }
       };
 
+  /**
+   * A SqlNodeList that has a single element that is an empty list.
+   */
+  public static final SqlNodeList SINGLETON_EMPTY =
+      new SqlNodeList(ImmutableList.of(EMPTY), SqlParserPos.ZERO);
+
+  /**
+   * A SqlNodeList that has a single element that is a star identifier.
+   */
+  public static final SqlNodeList SINGLETON_STAR =
+      new SqlNodeList(ImmutableList.of(SqlIdentifier.star(SqlParserPos.ZERO)),
+          SqlParserPos.ZERO);
+
   //~ Instance fields --------------------------------------------------------
 
   private final List<SqlNode> list;
@@ -105,42 +119,21 @@ public class SqlNodeList extends SqlNode implements Iterable<SqlNode> {
       SqlWriter writer,
       int leftPrec,
       int rightPrec) {
-    final SqlWriter.Frame frame =
-        ((leftPrec > 0) || (rightPrec > 0)) ? writer.startList("(", ")")
-            : writer.startList("", "");
-    commaList(writer);
-    writer.endList(frame);
+    final SqlWriter.FrameTypeEnum frameType =
+        (leftPrec > 0 || rightPrec > 0)
+            ? SqlWriter.FrameTypeEnum.PARENTHESES
+            : SqlWriter.FrameTypeEnum.SIMPLE;
+    writer.list(frameType, SqlWriter.COMMA, this);
   }
 
+  @Deprecated // to be removed before 2.0
   void commaList(SqlWriter writer) {
-    // The precedence of the comma operator if low but not zero. For
-    // instance, this ensures parentheses in
-    //    select x, (select * from foo order by z), y from t
-    for (SqlNode node : list) {
-      writer.sep(",");
-      node.unparse(writer, 2, 3);
-    }
+    unparse(writer, 0, 0);
   }
 
-  void andOrList(SqlWriter writer, SqlKind sepKind) {
-    SqlBinaryOperator sepOp =
-        sepKind == SqlKind.AND
-            ? SqlStdOperatorTable.AND
-            : SqlStdOperatorTable.OR;
-    for (int i = 0; i < list.size(); i++) {
-      SqlNode node = list.get(i);
-      writer.sep(sepKind.name(), false);
-
-      // The precedence pulling on the LHS of a node is the
-      // right-precedence of the separator operator, except at the start
-      // of the list; similarly for the RHS of a node. If the operator
-      // has left precedence 4 and right precedence 5, the precedences
-      // in a 3-node list will look as follows:
-      //   0 <- node1 -> 4  5 <- node2 -> 4  5 <- node3 -> 0
-      int lprec = (i == 0) ? 0 : sepOp.getRightPrec();
-      int rprec = (i == (list.size() - 1)) ? 0 : sepOp.getLeftPrec();
-      node.unparse(writer, lprec, rprec);
-    }
+  @Deprecated // to be removed before 2.0
+  void andOrList(SqlWriter writer, SqlBinaryOperator sepOp) {
+    writer.list(SqlWriter.FrameTypeEnum.WHERE_LIST, sepOp, this);
   }
 
   public void validate(SqlValidator validator, SqlValidatorScope scope) {
diff --git a/core/src/main/java/org/apache/calcite/sql/SqlOperator.java b/core/src/main/java/org/apache/calcite/sql/SqlOperator.java
index 1eb330d..3c96f02 100644
--- a/core/src/main/java/org/apache/calcite/sql/SqlOperator.java
+++ b/core/src/main/java/org/apache/calcite/sql/SqlOperator.java
@@ -19,6 +19,7 @@ package org.apache.calcite.sql;
 import org.apache.calcite.linq4j.Ord;
 import org.apache.calcite.rel.type.RelDataType;
 import org.apache.calcite.rel.type.RelDataTypeFactory;
+import org.apache.calcite.sql.fun.SqlStdOperatorTable;
 import org.apache.calcite.sql.parser.SqlParserPos;
 import org.apache.calcite.sql.type.SqlOperandTypeChecker;
 import org.apache.calcite.sql.type.SqlOperandTypeInference;
@@ -332,25 +333,40 @@ public abstract class SqlOperator {
     getSyntax().unparse(writer, this, call, leftPrec, rightPrec);
   }
 
-  // REVIEW jvs 9-June-2006: See http://issues.eigenbase.org/browse/FRG-149
-  // for why this method exists.
+  @Deprecated // to be removed before 2.0
   protected void unparseListClause(SqlWriter writer, SqlNode clause) {
-    unparseListClause(writer, clause, null);
+    final SqlNodeList nodeList =
+        clause instanceof SqlNodeList
+            ? (SqlNodeList) clause
+            : SqlNodeList.of(clause);
+    writer.list(SqlWriter.FrameTypeEnum.SIMPLE, SqlWriter.COMMA, nodeList);
   }
 
+  @Deprecated // to be removed before 2.0
   protected void unparseListClause(
       SqlWriter writer,
       SqlNode clause,
       SqlKind sepKind) {
-    if (clause instanceof SqlNodeList) {
-      if (sepKind != null) {
-        ((SqlNodeList) clause).andOrList(writer, sepKind);
-      } else {
-        ((SqlNodeList) clause).commaList(writer);
-      }
+    final SqlNodeList nodeList =
+        clause instanceof SqlNodeList
+            ? (SqlNodeList) clause
+            : SqlNodeList.of(clause);
+    final SqlBinaryOperator sepOp;
+    if (sepKind == null) {
+      sepOp = SqlWriter.COMMA;
     } else {
-      clause.unparse(writer, 0, 0);
+      switch (sepKind) {
+      case AND:
+        sepOp = SqlStdOperatorTable.AND;
+        break;
+      case OR:
+        sepOp = SqlStdOperatorTable.OR;
+        break;
+      default:
+        throw new AssertionError();
+      }
     }
+    writer.list(SqlWriter.FrameTypeEnum.SIMPLE, sepOp, nodeList);
   }
 
   // override Object
diff --git a/core/src/main/java/org/apache/calcite/sql/SqlOrderBy.java b/core/src/main/java/org/apache/calcite/sql/SqlOrderBy.java
index 57c2a71..e21fe4d 100644
--- a/core/src/main/java/org/apache/calcite/sql/SqlOrderBy.java
+++ b/core/src/main/java/org/apache/calcite/sql/SqlOrderBy.java
@@ -90,10 +90,8 @@ public class SqlOrderBy extends SqlCall {
       orderBy.query.unparse(writer, getLeftPrec(), getRightPrec());
       if (orderBy.orderList != SqlNodeList.EMPTY) {
         writer.sep(getName());
-        final SqlWriter.Frame listFrame =
-            writer.startList(SqlWriter.FrameTypeEnum.ORDER_BY_LIST);
-        unparseListClause(writer, orderBy.orderList);
-        writer.endList(listFrame);
+        writer.list(SqlWriter.FrameTypeEnum.ORDER_BY_LIST, SqlWriter.COMMA,
+            orderBy.orderList);
       }
       if (orderBy.offset != null) {
         final SqlWriter.Frame frame2 =
diff --git a/core/src/main/java/org/apache/calcite/sql/SqlSelectOperator.java b/core/src/main/java/org/apache/calcite/sql/SqlSelectOperator.java
index 8f7f602..f55072c 100644
--- a/core/src/main/java/org/apache/calcite/sql/SqlSelectOperator.java
+++ b/core/src/main/java/org/apache/calcite/sql/SqlSelectOperator.java
@@ -16,6 +16,7 @@
  */
 package org.apache.calcite.sql;
 
+import org.apache.calcite.sql.fun.SqlStdOperatorTable;
 import org.apache.calcite.sql.parser.SqlParserPos;
 import org.apache.calcite.sql.type.ReturnTypes;
 import org.apache.calcite.sql.util.SqlBasicVisitor;
@@ -133,6 +134,7 @@ public class SqlSelectOperator extends SqlOperator {
     }
   }
 
+  @SuppressWarnings("deprecation")
   public void unparse(
       SqlWriter writer,
       SqlCall call,
@@ -155,14 +157,12 @@ public class SqlSelectOperator extends SqlOperator {
       keyword.unparse(writer, 0, 0);
     }
     writer.topN(select.fetch, select.offset);
-    SqlNode selectClause = select.selectList;
-    if (selectClause == null) {
-      selectClause = SqlIdentifier.star(SqlParserPos.ZERO);
-    }
-    final SqlWriter.Frame selectListFrame =
-        writer.startList(SqlWriter.FrameTypeEnum.SELECT_LIST);
-    unparseListClause(writer, selectClause);
-    writer.endList(selectListFrame);
+    final SqlNodeList selectClause =
+        select.selectList != null
+            ? select.selectList
+            : SqlNodeList.of(SqlIdentifier.star(SqlParserPos.ZERO));
+    writer.list(SqlWriter.FrameTypeEnum.SELECT_LIST, SqlWriter.COMMA,
+        selectClause);
 
     if (select.from != null) {
       // Calcite SQL requires FROM but MySQL does not.
@@ -187,15 +187,15 @@ public class SqlSelectOperator extends SqlOperator {
         SqlNode node = select.where;
 
         // decide whether to split on ORs or ANDs
-        SqlKind whereSepKind = SqlKind.AND;
+        SqlBinaryOperator whereSep = SqlStdOperatorTable.AND;
         if ((node instanceof SqlCall)
             && node.getKind() == SqlKind.OR) {
-          whereSepKind = SqlKind.OR;
+          whereSep = SqlStdOperatorTable.OR;
         }
 
         // unroll whereClause
         final List<SqlNode> list = new ArrayList<>(0);
-        while (node.getKind() == whereSepKind) {
+        while (node.getKind() == whereSep.kind) {
           assert node instanceof SqlCall;
           final SqlCall call1 = (SqlCall) node;
           list.add(0, call1.operand(1));
@@ -203,32 +203,20 @@ public class SqlSelectOperator extends SqlOperator {
         }
         list.add(0, node);
 
-        // unparse in a WhereList frame
-        final SqlWriter.Frame whereFrame =
-            writer.startList(SqlWriter.FrameTypeEnum.WHERE_LIST);
-        unparseListClause(
-            writer,
-            new SqlNodeList(
-                list,
-                select.where.getParserPosition()),
-            whereSepKind);
-        writer.endList(whereFrame);
+        // unparse in a WHERE_LIST frame
+        writer.list(SqlWriter.FrameTypeEnum.WHERE_LIST, whereSep,
+            new SqlNodeList(list, select.where.getParserPosition()));
       } else {
         select.where.unparse(writer, 0, 0);
       }
     }
     if (select.groupBy != null) {
       writer.sep("GROUP BY");
-      final SqlWriter.Frame groupFrame =
-          writer.startList(SqlWriter.FrameTypeEnum.GROUP_BY_LIST);
-      if (select.groupBy.getList().isEmpty()) {
-        final SqlWriter.Frame frame =
-            writer.startList(SqlWriter.FrameTypeEnum.SIMPLE, "(", ")");
-        writer.endList(frame);
-      } else {
-        unparseListClause(writer, select.groupBy);
-      }
-      writer.endList(groupFrame);
+      final SqlNodeList groupBy =
+          select.groupBy.size() == 0 ? SqlNodeList.SINGLETON_EMPTY
+              : select.groupBy;
+      writer.list(SqlWriter.FrameTypeEnum.GROUP_BY_LIST, SqlWriter.COMMA,
+          groupBy);
     }
     if (select.having != null) {
       writer.sep("HAVING");
@@ -236,20 +224,13 @@ public class SqlSelectOperator extends SqlOperator {
     }
     if (select.windowDecls.size() > 0) {
       writer.sep("WINDOW");
-      final SqlWriter.Frame windowFrame =
-          writer.startList(SqlWriter.FrameTypeEnum.WINDOW_DECL_LIST);
-      for (SqlNode windowDecl : select.windowDecls) {
-        writer.sep(",");
-        windowDecl.unparse(writer, 0, 0);
-      }
-      writer.endList(windowFrame);
+      writer.list(SqlWriter.FrameTypeEnum.WINDOW_DECL_LIST, SqlWriter.COMMA,
+          select.windowDecls);
     }
     if (select.orderBy != null && select.orderBy.size() > 0) {
       writer.sep("ORDER BY");
-      final SqlWriter.Frame orderFrame =
-          writer.startList(SqlWriter.FrameTypeEnum.ORDER_BY_LIST);
-      unparseListClause(writer, select.orderBy);
-      writer.endList(orderFrame);
+      writer.list(SqlWriter.FrameTypeEnum.ORDER_BY_LIST, SqlWriter.COMMA,
+          select.orderBy);
     }
     writer.fetchOffset(select.fetch, select.offset);
     writer.endList(selectFrame);
diff --git a/core/src/main/java/org/apache/calcite/sql/SqlWithinGroupOperator.java b/core/src/main/java/org/apache/calcite/sql/SqlWithinGroupOperator.java
index 5faaf64..b20fee9 100644
--- a/core/src/main/java/org/apache/calcite/sql/SqlWithinGroupOperator.java
+++ b/core/src/main/java/org/apache/calcite/sql/SqlWithinGroupOperator.java
@@ -48,7 +48,7 @@ public class SqlWithinGroupOperator extends SqlBinaryOperator {
     final SqlWriter.Frame orderFrame =
         writer.startList(SqlWriter.FrameTypeEnum.ORDER_BY_LIST, "(", ")");
     writer.keyword("ORDER BY");
-    ((SqlNodeList) call.operand(1)).commaList(writer);
+    ((SqlNodeList) call.operand(1)).unparse(writer, 0, 0);
     writer.endList(orderFrame);
   }
 
diff --git a/core/src/main/java/org/apache/calcite/sql/SqlWriter.java b/core/src/main/java/org/apache/calcite/sql/SqlWriter.java
index 6ecc15c..b26489e 100644
--- a/core/src/main/java/org/apache/calcite/sql/SqlWriter.java
+++ b/core/src/main/java/org/apache/calcite/sql/SqlWriter.java
@@ -16,8 +16,11 @@
  */
 package org.apache.calcite.sql;
 
+import org.apache.calcite.sql.fun.SqlStdOperatorTable;
 import org.apache.calcite.sql.util.SqlString;
 
+import java.util.function.Consumer;
+
 /**
  * A <code>SqlWriter</code> is the target to construct a SQL statement from a
  * parse tree. It deals with dialect differences; for example, Oracle quotes
@@ -72,6 +75,12 @@ public interface SqlWriter {
     SIMPLE,
 
     /**
+     * Comma-separated list surrounded by parentheses.
+     * The parentheses are present even if the list is empty.
+     */
+    PARENTHESES,
+
+    /**
      * The SELECT clause of a SELECT statement.
      */
     SELECT_LIST,
@@ -176,7 +185,7 @@ public interface SqlWriter {
      * <li><code>GROUP BY x, FLOOR(y)</code></li>
      * </ul>
      */
-    SUB_QUERY,
+    SUB_QUERY(true),
 
     /**
      * Set operation.
@@ -221,7 +230,22 @@ public interface SqlWriter {
      * <li><code>"A"."B"."C"</code></li>
      * </ul>
      */
-    IDENTIFIER(false);
+    IDENTIFIER(false),
+
+    /**
+     * Alias ("AS"). No indent.
+     */
+    AS(false),
+
+    /**
+     * CASE expression.
+     */
+    CASE,
+
+    /**
+     * Same behavior as user-defined frame type.
+     */
+    OTHER;
 
     private final boolean needsIndent;
 
@@ -266,6 +290,18 @@ public interface SqlWriter {
     }
   }
 
+  /** Comma operator.
+   *
+   * <p>Defined in {@code SqlWriter} because it is only used while converting
+   * {@link SqlNode} to SQL;
+   * see {@link SqlWriter#list(FrameTypeEnum, SqlBinaryOperator, SqlNodeList)}.
+   *
+   * <p>The precedence of the comma operator is low but not zero. For
+   * instance, this ensures parentheses in
+   * {@code select x, (select * from foo order by z), y from t}. */
+  SqlBinaryOperator COMMA =
+      new SqlBinaryOperator(",", SqlKind.OTHER, 2, false, null, null, null);
+
   //~ Methods ----------------------------------------------------------------
 
   /**
@@ -407,6 +443,7 @@ public interface SqlWriter {
    * Starts a list with no opening string.
    *
    * @param frameType Type of list. For example, a SELECT list will be
+   * governed according to SELECT-list formatting preferences.
    */
   Frame startList(FrameTypeEnum frameType);
 
@@ -429,6 +466,20 @@ public interface SqlWriter {
   void endList(Frame frame);
 
   /**
+   * Writes a list.
+   */
+  SqlWriter list(FrameTypeEnum frameType, Consumer<SqlWriter> action);
+
+  /**
+   * Writes a list separated by a binary operator
+   * ({@link SqlStdOperatorTable#AND AND},
+   * {@link SqlStdOperatorTable#OR OR}, or
+   * {@link #COMMA COMMA}).
+   */
+  SqlWriter list(FrameTypeEnum frameType, SqlBinaryOperator sepOp,
+      SqlNodeList list);
+
+  /**
    * Writes a list separator, unless the separator is "," and this is the
    * first occurrence in the list.
    *
diff --git a/core/src/main/java/org/apache/calcite/sql/SqlWriterConfig.java b/core/src/main/java/org/apache/calcite/sql/SqlWriterConfig.java
new file mode 100644
index 0000000..7e9c1c6
--- /dev/null
+++ b/core/src/main/java/org/apache/calcite/sql/SqlWriterConfig.java
@@ -0,0 +1,434 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to you 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 org.apache.calcite.sql;
+
+import org.apache.calcite.sql.pretty.SqlPrettyWriter;
+import org.apache.calcite.util.ImmutableBeans;
+
+/** Configuration for {@link SqlWriter} and {@link SqlPrettyWriter}. */
+public interface SqlWriterConfig {
+  /** Returns the dialect. */
+  @ImmutableBeans.Property
+  SqlDialect dialect();
+
+  /** Sets {@link #dialect()}. */
+  SqlWriterConfig withDialect(SqlDialect dialect);
+
+  /** Returns whether to print keywords (SELECT, AS, etc.) in lower-case.
+   * Default is false: keywords are printed in upper-case. */
+  @ImmutableBeans.Property
+  @ImmutableBeans.BooleanDefault(false)
+  boolean keywordsLowerCase();
+
+  /** Sets {@link #keywordsLowerCase}. */
+  SqlWriterConfig withKeywordsLowerCase(boolean keywordsLowerCase);
+
+  /** Returns whether to quote all identifiers, even those which would be
+   * correct according to the rules of the {@link SqlDialect} if quotation
+   * marks were omitted. Default is true. */
+  @ImmutableBeans.Property
+  @ImmutableBeans.BooleanDefault(true)
+  boolean quoteAllIdentifiers();
+
+  /** Sets {@link #quoteAllIdentifiers}. */
+  SqlWriterConfig withQuoteAllIdentifiers(boolean quoteAllIdentifiers);
+
+  /** Returns the number of spaces indentation. Default is 4. */
+  @ImmutableBeans.Property
+  @ImmutableBeans.IntDefault(4)
+  int indentation();
+
+  /** Sets {@link #indentation}. */
+  SqlWriterConfig withIndentation(int indentation);
+
+  /** Returns whether a clause (FROM, WHERE, GROUP BY, HAVING, WINDOW,
+   * ORDER BY) starts a new line. Default is true. SELECT is always at the
+   * start of a line. */
+  @ImmutableBeans.Property
+  @ImmutableBeans.BooleanDefault(true)
+  boolean clauseStartsLine();
+
+  /** Sets {@link #clauseStartsLine}. */
+  SqlWriterConfig withClauseStartsLine(boolean clauseStartsLine);
+
+  /** Returns whether a clause (FROM, WHERE, GROUP BY, HAVING, WINDOW,
+   * ORDER BY) is followed by a new line. Default is false. */
+  @ImmutableBeans.Property
+  @ImmutableBeans.BooleanDefault(false)
+  boolean clauseEndsLine();
+
+  /** Sets {@link #clauseEndsLine()}. */
+  SqlWriterConfig withClauseEndsLine(boolean clauseEndsLine);
+
+  /** Returns whether each item in a SELECT list, GROUP BY list, or ORDER BY
+   * list is on its own line.
+   *
+   * <p>Default is false;
+   * this property is superseded by {@link #selectFolding()},
+   * {@link #groupByFolding()}, {@link #orderByFolding()}. */
+  @ImmutableBeans.Property
+  @ImmutableBeans.BooleanDefault(false)
+  boolean selectListItemsOnSeparateLines();
+
+  /** Sets {@link #selectListItemsOnSeparateLines}. */
+  SqlWriterConfig withSelectListItemsOnSeparateLines(
+      boolean selectListItemsOnSeparateLines);
+
+  /** Returns the line-folding policy for lists in the SELECT, GROUP BY and
+   * ORDER clauses, for items in the SET clause of UPDATE, and for items in
+   * VALUES.
+   *
+   * @see #foldLength()
+   *
+   * <p>If not set, the values of
+   * {@link #selectListItemsOnSeparateLines()},
+   * {@link #valuesListNewline()},
+   * {@link #updateSetListNewline()},
+   * {@link #windowDeclListNewline()} are used. */
+  @ImmutableBeans.Property
+  LineFolding lineFolding();
+
+  /** Sets {@link #lineFolding()}. */
+  SqlWriterConfig withLineFolding(LineFolding lineFolding);
+
+  /** Returns the line-folding policy for the SELECT clause.
+   * If not set, the value of {@link #lineFolding()} is used. */
+  @ImmutableBeans.Property
+  LineFolding selectFolding();
+
+  /** Sets {@link #selectFolding()}. */
+  SqlWriterConfig withSelectFolding(LineFolding lineFolding);
+
+  /** Returns the line-folding policy for the FROM clause (and JOIN).
+   * If not set, the value of {@link #lineFolding()} is used. */
+  @ImmutableBeans.Property
+  @ImmutableBeans.EnumDefault("TALL")
+  LineFolding fromFolding();
+
+  /** Sets {@link #fromFolding()}. */
+  SqlWriterConfig withFromFolding(LineFolding lineFolding);
+
+  /** Returns the line-folding policy for the WHERE clause.
+   * If not set, the value of {@link #lineFolding()} is used. */
+  @ImmutableBeans.Property
+  LineFolding whereFolding();
+
+  /** Sets {@link #whereFolding()}. */
+  SqlWriterConfig withWhereFolding(LineFolding lineFolding);
+
+  /** Returns the line-folding policy for the GROUP BY clause.
+   * If not set, the value of {@link #lineFolding()} is used. */
+  @ImmutableBeans.Property
+  LineFolding groupByFolding();
+
+  /** Sets {@link #groupByFolding()}. */
+  SqlWriterConfig withGroupByFolding(LineFolding lineFolding);
+
+  /** Returns the line-folding policy for the HAVING clause.
+   * If not set, the value of {@link #lineFolding()} is used. */
+  @ImmutableBeans.Property
+  LineFolding havingFolding();
+
+  /** Sets {@link #havingFolding()}. */
+  SqlWriterConfig withHavingFolding(LineFolding lineFolding);
+
+  /** Returns the line-folding policy for the WINDOW clause.
+   * If not set, the value of {@link #lineFolding()} is used. */
+  @ImmutableBeans.Property
+  LineFolding windowFolding();
+
+  /** Sets {@link #windowFolding()}. */
+  SqlWriterConfig withWindowFolding(LineFolding lineFolding);
+
+  /** Returns the line-folding policy for the MATCH_RECOGNIZE clause.
+   * If not set, the value of {@link #lineFolding()} is used. */
+  @ImmutableBeans.Property
+  LineFolding matchFolding();
+
+  /** Sets {@link #matchFolding()}. */
+  SqlWriterConfig withMatchFolding(LineFolding lineFolding);
+
+  /** Returns the line-folding policy for the ORDER BY clause.
+   * If not set, the value of {@link #lineFolding()} is used. */
+  @ImmutableBeans.Property
+  LineFolding orderByFolding();
+
+  /** Sets {@link #orderByFolding()}. */
+  SqlWriterConfig withOrderByFolding(LineFolding lineFolding);
+
+  /** Returns the line-folding policy for the OVER clause or a window
+   * declaration. If not set, the value of {@link #lineFolding()} is used. */
+  @ImmutableBeans.Property
+  LineFolding overFolding();
+
+  /** Sets {@link #overFolding()}. */
+  SqlWriterConfig withOverFolding(LineFolding lineFolding);
+
+  /** Returns the line-folding policy for the VALUES expression.
+   * If not set, the value of {@link #lineFolding()} is used. */
+  @ImmutableBeans.Property
+  LineFolding valuesFolding();
+
+  /** Sets {@link #valuesFolding()}. */
+  SqlWriterConfig withValuesFolding(LineFolding lineFolding);
+
+  /** Returns the line-folding policy for the SET clause of an UPDATE statement.
+   * If not set, the value of {@link #lineFolding()} is used. */
+  @ImmutableBeans.Property
+  LineFolding updateSetFolding();
+
+  /** Sets {@link #updateSetFolding()}. */
+  SqlWriterConfig withUpdateSetFolding(LineFolding lineFolding);
+
+  /**
+   * Returns whether to use a fix for SELECT list indentations.
+   *
+   * <ul>
+   * <li>If set to "false":
+   *
+   * <blockquote><pre>
+   * SELECT
+   *     A as A,
+   *         B as B,
+   *         C as C,
+   *     D
+   * </pre></blockquote>
+   *
+   * <li>If set to "true" (the default):
+   *
+   * <blockquote><pre>
+   * SELECT
+   *     A as A,
+   *     B as B,
+   *     C as C,
+   *     D
+   * </pre></blockquote>
+   * </ul>
+   */
+  @ImmutableBeans.Property
+  @ImmutableBeans.BooleanDefault(true)
+  boolean selectListExtraIndentFlag();
+
+  /** Sets {@link #selectListExtraIndentFlag}. */
+  SqlWriterConfig withSelectListExtraIndentFlag(boolean selectListExtraIndentFlag);
+
+  /** Returns whether each declaration in a WINDOW clause should be on its own
+   * line.
+   *
+   * <p>Default is true;
+   * this property is superseded by {@link #windowFolding()}. */
+  @ImmutableBeans.Property
+  @ImmutableBeans.BooleanDefault(true)
+  boolean windowDeclListNewline();
+
+  /** Sets {@link #windowDeclListNewline}. */
+  SqlWriterConfig withWindowDeclListNewline(boolean windowDeclListNewline);
+
+  /** Returns whether each row in a VALUES clause should be on its own
+   * line.
+   *
+   * <p>Default is true;
+   * this property is superseded by {@link #valuesFolding()}. */
+  @ImmutableBeans.Property
+  @ImmutableBeans.BooleanDefault(true)
+  boolean valuesListNewline();
+
+  /** Sets {@link #valuesListNewline}. */
+  SqlWriterConfig withValuesListNewline(boolean valuesListNewline);
+
+  /** Returns whether each assignment in the SET clause of an UPDATE or MERGE
+   * statement should be on its own line.
+   *
+   * <p>Default is true;
+   * this property is superseded by {@link #updateSetFolding()}. */
+  @ImmutableBeans.Property
+  @ImmutableBeans.BooleanDefault(true)
+  boolean updateSetListNewline();
+
+  /** Sets {@link #updateSetListNewline}. */
+  SqlWriterConfig withUpdateSetListNewline(boolean updateSetListNewline);
+
+  /** Returns whether a WINDOW clause should start its own line. */
+  @ImmutableBeans.Property
+  @ImmutableBeans.BooleanDefault(false)
+  boolean windowNewline();
+
+  /** Sets {@link #windowNewline}. */
+  SqlWriterConfig withWindowNewline(boolean windowNewline);
+
+  /** Returns whether commas in SELECT, GROUP BY and ORDER clauses should
+   * appear at the start of the line. Default is false. */
+  @ImmutableBeans.Property
+  @ImmutableBeans.BooleanDefault(false)
+  boolean leadingComma();
+
+  /** Sets {@link #leadingComma()}. */
+  SqlWriterConfig withLeadingComma(boolean leadingComma);
+
+  /** Returns the sub-query style.
+   * Default is {@link SqlWriter.SubQueryStyle#HYDE}. */
+  @ImmutableBeans.Property
+  @ImmutableBeans.EnumDefault("HYDE")
+  SqlWriter.SubQueryStyle subQueryStyle();
+
+  /** Sets {@link #subQueryStyle}. */
+  SqlWriterConfig withSubQueryStyle(SqlWriter.SubQueryStyle subQueryStyle);
+
+  /** Returns whether to print a newline before each AND or OR (whichever is
+   * higher level) in WHERE clauses.
+   *
+   * <p>NOTE: Ignored when alwaysUseParentheses is set to true. */
+  @ImmutableBeans.Property
+  @ImmutableBeans.BooleanDefault(false)
+  boolean whereListItemsOnSeparateLines();
+
+  /** Sets {@link #whereListItemsOnSeparateLines}. */
+  SqlWriterConfig withWhereListItemsOnSeparateLines(
+      boolean whereListItemsOnSeparateLines);
+
+  /** Returns whether expressions should always be included in parentheses.
+   * Default is false. */
+  @ImmutableBeans.Property
+  @ImmutableBeans.BooleanDefault(false)
+  boolean alwaysUseParentheses();
+
+  /** Sets {@link #alwaysUseParentheses}. */
+  SqlWriterConfig withAlwaysUseParentheses(boolean alwaysUseParentheses);
+
+  /** Returns the maximum line length. Default is zero, which means there is
+   * no maximum. */
+  @ImmutableBeans.Property
+  @ImmutableBeans.IntDefault(0)
+  int lineLength();
+
+  /** Sets {@link #lineLength}. */
+  SqlWriterConfig withLineLength(int lineLength);
+
+  /** Returns the line length at which items are chopped or folded (for clauses
+   * that have chosen {@link LineFolding#CHOP} or {@link LineFolding#FOLD}).
+   * Default is 80. */
+  @ImmutableBeans.Property
+  @ImmutableBeans.IntDefault(80)
+  int foldLength();
+
+  /** Sets {@link #foldLength()}. */
+  SqlWriterConfig withFoldLength(int lineLength);
+
+  /** Returns whether the WHEN, THEN and ELSE clauses of a CASE expression
+   * appear at the start of a new line. The default is false. */
+  @ImmutableBeans.Property
+  @ImmutableBeans.BooleanDefault(false)
+  boolean caseClausesOnNewLines();
+
+  /** Sets {@link #caseClausesOnNewLines}. */
+  SqlWriterConfig withCaseClausesOnNewLines(boolean caseClausesOnNewLines);
+
+  /** Policy for how to do deal with long lines.
+   *
+   * <p>The following examples all have
+   * {@link #clauseEndsLine ClauseEndsLine=true},
+   * {@link #indentation Indentation=4}, and
+   * {@link #foldLength FoldLength=25} (so that the long {@code SELECT}
+   * clause folds but the shorter {@code GROUP BY} clause does not).
+   *
+   * <p>Note that {@link #clauseEndsLine ClauseEndsLine} is observed in
+   * STEP and TALL modes, and in CHOP mode when a line is long.
+   *
+   * <table border=1>
+   * <caption>SQL formatted with each Folding value</caption>
+   * <tr>
+   *   <th>Folding</th>
+   *   <th>Example</th>
+   * </tr>
+   *
+   * <tr>
+   *   <td>WIDE</td>
+   *   <td><pre>
+   * SELECT abc, def, ghi, jkl, mno, pqr
+   * FROM t
+   * GROUP BY abc, def</pre></td>
+   * </tr>
+   *
+   * <tr>
+   *   <td>STEP</td>
+   *   <td><pre>
+   * SELECT
+   *     abc, def, ghi, jkl, mno, pqr
+   * FROM t
+   * GROUP BY
+   *     abc, def</pre></td>
+   * </tr>
+   *
+   * <tr>
+   *   <td>FOLD</td>
+   *   <td><pre>
+   * SELECT abc, def, ghi,
+   *     jkl, mno, pqr
+   * FROM t
+   * GROUP BY abc, def</pre></td>
+   * </tr>
+   *
+   * <tr>
+   *   <td>CHOP</td>
+   *   <td><pre>
+   * SELECT
+   *     abc,
+   *     def,
+   *     ghi,
+   *     jkl,
+   *     mno,
+   *     pqr
+   * FROM t
+   * GROUP BY abc, def</pre></td>
+   * </tr>
+   *
+   * <tr>
+   *   <td>TALL</td>
+   *   <td><pre>
+   * SELECT
+   *     abc,
+   *     def,
+   *     ghi,
+   *     jkl,
+   *     mno,
+   *     pqr
+   * FROM t
+   * GROUP BY
+   *     abc,
+   *     def</pre></td>
+   * </tr>
+   * </table>
+   */
+  enum LineFolding {
+    /** Do not wrap. Items are on the same line, regardless of length. */
+    WIDE,
+
+    /** As {@link #WIDE} but start a new line if {@link #clauseEndsLine()}. */
+    STEP,
+
+    /** Wrap if long. Items are on the same line, but if the line's length
+     * exceeds {@link #foldLength()}, move items to the next line. */
+    FOLD,
+
+    /** Chop down if long. Items are on the same line, but if the line grows
+     * longer than {@link #foldLength()}, put all items on separate lines. */
+    CHOP,
+
+    /** Wrap always. Items are on separate lines. */
+    TALL
+  }
+}
diff --git a/core/src/main/java/org/apache/calcite/sql/fun/SqlCaseOperator.java b/core/src/main/java/org/apache/calcite/sql/fun/SqlCaseOperator.java
index bf87050..4a05992 100644
--- a/core/src/main/java/org/apache/calcite/sql/fun/SqlCaseOperator.java
+++ b/core/src/main/java/org/apache/calcite/sql/fun/SqlCaseOperator.java
@@ -127,9 +127,6 @@ import static org.apache.calcite.util.Static.RESOURCE;
 public class SqlCaseOperator extends SqlOperator {
   public static final SqlCaseOperator INSTANCE = new SqlCaseOperator();
 
-  private static final SqlWriter.FrameType FRAME_TYPE =
-      SqlWriter.FrameTypeEnum.create("CASE");
-
   //~ Constructors -----------------------------------------------------------
 
   private SqlCaseOperator() {
@@ -331,7 +328,7 @@ public class SqlCaseOperator extends SqlOperator {
       int rightPrec) {
     SqlCase kase = (SqlCase) call_;
     final SqlWriter.Frame frame =
-        writer.startList(FRAME_TYPE, "CASE", "END");
+        writer.startList(SqlWriter.FrameTypeEnum.CASE, "CASE", "END");
     assert kase.whenList.size() == kase.thenList.size();
     if (kase.value != null) {
       kase.value.unparse(writer, 0, 0);
diff --git a/core/src/main/java/org/apache/calcite/sql/fun/SqlStdOperatorTable.java b/core/src/main/java/org/apache/calcite/sql/fun/SqlStdOperatorTable.java
index 8899778..755fc4c 100644
--- a/core/src/main/java/org/apache/calcite/sql/fun/SqlStdOperatorTable.java
+++ b/core/src/main/java/org/apache/calcite/sql/fun/SqlStdOperatorTable.java
@@ -74,6 +74,7 @@ import java.util.List;
  * the standard operators and functions.
  */
 public class SqlStdOperatorTable extends ReflectiveSqlOperatorTable {
+
   //~ Static fields/initializers ---------------------------------------------
 
   /**
diff --git a/core/src/main/java/org/apache/calcite/sql/pretty/SqlPrettyWriter.java b/core/src/main/java/org/apache/calcite/sql/pretty/SqlPrettyWriter.java
index f5a52f8..e29d2ef 100644
--- a/core/src/main/java/org/apache/calcite/sql/pretty/SqlPrettyWriter.java
+++ b/core/src/main/java/org/apache/calcite/sql/pretty/SqlPrettyWriter.java
@@ -17,12 +17,16 @@
 package org.apache.calcite.sql.pretty;
 
 import org.apache.calcite.avatica.util.Spaces;
+import org.apache.calcite.sql.SqlBinaryOperator;
 import org.apache.calcite.sql.SqlDialect;
 import org.apache.calcite.sql.SqlNode;
+import org.apache.calcite.sql.SqlNodeList;
 import org.apache.calcite.sql.SqlWriter;
+import org.apache.calcite.sql.SqlWriterConfig;
 import org.apache.calcite.sql.dialect.AnsiSqlDialect;
+import org.apache.calcite.sql.dialect.CalciteSqlDialect;
 import org.apache.calcite.sql.util.SqlString;
-import org.apache.calcite.util.Unsafe;
+import org.apache.calcite.util.ImmutableBeans;
 import org.apache.calcite.util.Util;
 import org.apache.calcite.util.trace.CalciteLogger;
 
@@ -32,7 +36,6 @@ import com.google.common.collect.ImmutableList;
 import org.slf4j.LoggerFactory;
 
 import java.io.PrintWriter;
-import java.io.StringWriter;
 import java.lang.reflect.InvocationTargetException;
 import java.lang.reflect.Method;
 import java.util.ArrayDeque;
@@ -44,6 +47,8 @@ import java.util.Map;
 import java.util.Objects;
 import java.util.Properties;
 import java.util.Set;
+import java.util.function.Consumer;
+import javax.annotation.Nonnull;
 
 /**
  * Pretty printer for SQL statements.
@@ -57,69 +62,189 @@ import java.util.Set;
  * <th>Description</th>
  * <th>Default</th>
  * </tr>
+ *
  * <tr>
- * <td>{@link #setSelectListItemsOnSeparateLines SelectListItemsOnSeparateLines}
- * </td>
- * <td>Whether each item in the select clause is on its own line</td>
+ * <td>{@link SqlWriterConfig#clauseStartsLine()} ClauseStartsLine}</td>
+ * <td>Whether a clause ({@code FROM}, {@code WHERE}, {@code GROUP BY},
+ * {@code HAVING}, {@code WINDOW}, {@code ORDER BY}) starts a new line.
+ * {@code SELECT} is always at the start of a line.</td>
+ * <td>true</td>
+ * </tr>
+ *
+ * <tr>
+ * <td>{@link SqlWriterConfig#clauseEndsLine ClauseEndsLine}</td>
+ * <td>Whether a clause ({@code SELECT}, {@code FROM}, {@code WHERE},
+ * {@code GROUP BY}, {@code HAVING}, {@code WINDOW}, {@code ORDER BY}) is
+ * followed by a new line.</td>
  * <td>false</td>
  * </tr>
+ *
  * <tr>
- * <td>{@link #setCaseClausesOnNewLines CaseClausesOnNewLines}</td>
+ * <td>{@link SqlWriterConfig#caseClausesOnNewLines CaseClausesOnNewLines}</td>
  * <td>Whether the WHEN, THEN and ELSE clauses of a CASE expression appear at
  * the start of a new line.</td>
  * <td>false</td>
  * </tr>
+ *
  * <tr>
- * <td>{@link #setIndentation Indentation}</td>
+ * <td>{@link SqlWriterConfig#indentation Indentation}</td>
  * <td>Number of spaces to indent</td>
  * <td>4</td>
  * </tr>
+ *
  * <tr>
- * <td>{@link #setKeywordsLowerCase KeywordsLowerCase}</td>
+ * <td>{@link SqlWriterConfig#keywordsLowerCase KeywordsLowerCase}</td>
  * <td>Whether to print keywords (SELECT, AS, etc.) in lower-case.</td>
  * <td>false</td>
  * </tr>
+ *
  * <tr>
- * <td>{@link #isAlwaysUseParentheses ParenthesizeAllExprs}</td>
- * <td>Whether to enclose all expressions in parentheses, even if the operator
- * has high enough precedence that the parentheses are not required.
+ * <td>{@link SqlWriterConfig#alwaysUseParentheses AlwaysUseParentheses}</td>
+ * <td><p>Whether to enclose all expressions in parentheses, even if the
+ * operator has high enough precedence that the parentheses are not required.
  *
- * <p>For example, the parentheses are required in the expression <code>(a + b)
- * c</code> because the '*' operator has higher precedence than the '+'
- * operator, and so without the parentheses, the expression would be equivalent
- * to <code>a + (b * c)</code>. The fully-parenthesized expression, <code>((a +
- * b) * c)</code> is unambiguous even if you don't know the precedence of every
- * operator.</td>
- * <td></td>
+ * <p>For example, the parentheses are required in the expression
+ * {@code (a + b) * c} because the '*' operator has higher precedence than the
+ * '+' operator, and so without the parentheses, the expression would be
+ * equivalent to {@code a + (b * c)}. The fully-parenthesized expression,
+ * {@code ((a + b) * c)} is unambiguous even if you don't know the precedence
+ * of every operator.</td>
+ * <td>false</td>
  * </tr>
+ *
  * <tr>
- * <td>{@link #setQuoteAllIdentifiers QuoteAllIdentifiers}</td>
+ * <td>{@link SqlWriterConfig#quoteAllIdentifiers QuoteAllIdentifiers}</td>
  * <td>Whether to quote all identifiers, even those which would be correct
  * according to the rules of the {@link SqlDialect} if quotation marks were
  * omitted.</td>
  * <td>true</td>
  * </tr>
+ *
  * <tr>
- * <td>{@link #setSelectListItemsOnSeparateLines SelectListItemsOnSeparateLines}
- * </td>
- * <td>Whether each item in the select clause is on its own line.</td>
- * <td>false</td>
- * </tr>
- * <tr>
- * <td>{@link #setSubQueryStyle SubQueryStyle}</td>
+ * <td>{@link SqlWriterConfig#subQueryStyle SubQueryStyle}</td>
  * <td>Style for formatting sub-queries. Values are:
  * {@link org.apache.calcite.sql.SqlWriter.SubQueryStyle#HYDE Hyde},
  * {@link org.apache.calcite.sql.SqlWriter.SubQueryStyle#BLACK Black}.</td>
  *
  * <td>{@link org.apache.calcite.sql.SqlWriter.SubQueryStyle#HYDE Hyde}</td>
  * </tr>
+ *
  * <tr>
- * <td>{@link #setLineLength LineLength}</td>
- * <td>Set the desired maximum length for lines (to look nice in editors,
+ * <td>{@link SqlWriterConfig#lineLength LineLength}</td>
+ * <td>The desired maximum length for lines (to look nice in editors,
  * printouts, etc.).</td>
- * <td>0</td>
+ * <td>-1 (no maximum)</td>
+ * </tr>
+ *
+ * <tr>
+ * <td>{@link SqlWriterConfig#foldLength FoldLength}</td>
+ * <td>The line length at which lines are folded or chopped down
+ * (see {@code LineFolding}). Only has an effect if clauses are marked
+ * {@link SqlWriterConfig.LineFolding#CHOP CHOP} or
+ * {@link SqlWriterConfig.LineFolding#FOLD FOLD}.</td>
+ * <td>80</td>
+ * </tr>
+ *
+ * <tr>
+ * <td>{@link SqlWriterConfig#lineFolding LineFolding}</td>
+ * <td>How long lines are to be handled. Options are lines are
+ * WIDE (do not wrap),
+ * FOLD (wrap if long),
+ * CHOP (chop down if long),
+ * and TALL (wrap always).</td>
+ * <td>WIDE</td>
+ * </tr>
+ *
+ * <tr>
+ * <td>{@link SqlWriterConfig#selectFolding() SelectFolding}</td>
+ * <td>How the {@code SELECT} clause is to be folded.</td>
+ * <td>{@code LineFolding}</td>
  * </tr>
+ *
+ * <tr>
+ * <td>{@link SqlWriterConfig#fromFolding FromFolding}</td>
+ * <td>How the {@code FROM} clause and nested {@code JOIN} clauses are to be
+ * folded.</td>
+ * <td>{@code LineFolding}</td>
+ * </tr>
+ *
+ * <tr>
+ * <td>{@link SqlWriterConfig#whereFolding WhereFolding}</td>
+ * <td>How the {@code WHERE} clause is to be folded.</td>
+ * <td>{@code LineFolding}</td>
+ * </tr>
+ *
+ * <tr>
+ * <td>{@link SqlWriterConfig#groupByFolding GroupByFolding}</td>
+ * <td>How the {@code GROUP BY} clause is to be folded.</td>
+ * <td>{@code LineFolding}</td>
+ * </tr>
+ *
+ * <tr>
+ * <td>{@link SqlWriterConfig#havingFolding HavingFolding}</td>
+ * <td>How the {@code HAVING} clause is to be folded.</td>
+ * <td>{@code LineFolding}</td>
+ * </tr>
+ *
+ * <tr>
+ * <td>{@link SqlWriterConfig#orderByFolding OrderByFolding}</td>
+ * <td>How the {@code ORDER BY} clause is to be folded.</td>
+ * <td>{@code LineFolding}</td>
+ * </tr>
+ *
+ * <tr>
+ * <td>{@link SqlWriterConfig#windowFolding WindowFolding}</td>
+ * <td>How the {@code WINDOW} clause is to be folded.</td>
+ * <td>{@code LineFolding}</td>
+ * </tr>
+ *
+ * <tr>
+ * <td>{@link SqlWriterConfig#overFolding OverFolding}</td>
+ * <td>How window declarations in the {@code WINDOW} clause
+ * and in the {@code OVER} clause of aggregate functions are to be folded.</td>
+ * <td>{@code LineFolding}</td>
+ * </tr>
+ *
+ * <tr>
+ * <td>{@link SqlWriterConfig#valuesFolding ValuesFolding}</td>
+ * <td>How lists of values in the {@code VALUES} clause are to be folded.</td>
+ * <td>{@code LineFolding}</td>
+ * </tr>
+ *
+ * <tr>
+ * <td>{@link SqlWriterConfig#updateSetFolding UpdateSetFolding}</td>
+ * <td>How assignments in the {@code SET} clause of an {@code UPDATE} statement
+ * are to be folded.</td>
+ * <td>{@code LineFolding}</td>
+ * </tr>
+ *
  * </table>
+ *
+ * <p>The following options exist for backwards compatibility. They are
+ * used if {@link SqlWriterConfig#lineFolding LineFolding} and clause-specific
+ * options such as {@link SqlWriterConfig#selectFolding SelectFolding} are not
+ * specified:
+ *
+ * <ul>
+ *
+ * <li>{@link SqlWriterConfig#selectListItemsOnSeparateLines SelectListItemsOnSeparateLines}
+ * replaced by {@link SqlWriterConfig#selectFolding SelectFolding},
+ * {@link SqlWriterConfig#groupByFolding GroupByFolding}, and
+ * {@link SqlWriterConfig#orderByFolding OrderByFolding};
+ *
+ * <li>{@link SqlWriterConfig#updateSetListNewline UpdateSetListNewline}
+ * replaced by {@link SqlWriterConfig#updateSetFolding UpdateSetFolding};
+ *
+ * <li>{@link SqlWriterConfig#windowDeclListNewline WindowDeclListNewline}
+ * replaced by {@link SqlWriterConfig#windowFolding WindowFolding};
+ *
+ * <li>{@link SqlWriterConfig#windowNewline WindowNewline}
+ * replaced by {@link SqlWriterConfig#overFolding OverFolding};
+ *
+ * <li>{@link SqlWriterConfig#valuesListNewline ValuesListNewline}
+ * replaced by {@link SqlWriterConfig#valuesFolding ValuesFolding}.
+ *
+ * </ul>
  */
 public class SqlPrettyWriter implements SqlWriter {
   //~ Static fields/initializers ---------------------------------------------
@@ -132,96 +257,133 @@ public class SqlPrettyWriter implements SqlWriter {
    * Bean holding the default property values.
    */
   private static final Bean DEFAULT_BEAN =
-      new SqlPrettyWriter(AnsiSqlDialect.DEFAULT).getBean();
+      new SqlPrettyWriter(SqlPrettyWriter.config()
+          .withDialect(AnsiSqlDialect.DEFAULT)).getBean();
   protected static final String NL = System.getProperty("line.separator");
 
   //~ Instance fields --------------------------------------------------------
 
   private final SqlDialect dialect;
-  private final StringWriter sw = new StringWriter();
-  protected final PrintWriter pw;
+  private final StringBuilder buf;
   private final Deque<FrameImpl> listStack = new ArrayDeque<>();
   private ImmutableList.Builder<Integer> dynamicParameters;
   protected FrameImpl frame;
   private boolean needWhitespace;
   protected String nextWhitespace;
-  protected boolean alwaysUseParentheses;
-  private boolean keywordsLowerCase;
+  private SqlWriterConfig config;
   private Bean bean;
-  private boolean quoteAllIdentifiers;
-  private int indentation;
-  private boolean clauseStartsLine;
-  private boolean selectListItemsOnSeparateLines;
-  private boolean selectListExtraIndentFlag;
   private int currentIndent;
-  private boolean windowDeclListNewline;
-  private boolean updateSetListNewline;
-  private boolean windowNewline;
-  private SubQueryStyle subQueryStyle;
-  private boolean whereListItemsOnSeparateLines;
 
-  private boolean caseClausesOnNewLines;
-  private int lineLength;
-  private int charCount;
+  private int lineStart;
 
   //~ Constructors -----------------------------------------------------------
 
+  private SqlPrettyWriter(SqlWriterConfig config,
+      StringBuilder buf, boolean ignore) {
+    this.buf = Objects.requireNonNull(buf);
+    this.dialect = Objects.requireNonNull(config.dialect());
+    this.config = Objects.requireNonNull(config);
+    lineStart = 0;
+    reset();
+  }
+
+  /** Creates a writer with the given configuration
+   * and a given buffer to write to. */
+  public SqlPrettyWriter(@Nonnull SqlWriterConfig config,
+      @Nonnull StringBuilder buf) {
+    this(config, Objects.requireNonNull(buf), false);
+  }
+
+  /** Creates a writer with the given configuration and dialect,
+   * and a given print writer (or a private print writer if it is null). */
+  public SqlPrettyWriter(
+      SqlDialect dialect,
+      SqlWriterConfig config,
+      StringBuilder buf) {
+    this(config.withDialect(Objects.requireNonNull(dialect)), buf);
+  }
+
+  /** Creates a writer with the given configuration
+   * and a private print writer. */
+  @Deprecated
+  public SqlPrettyWriter(SqlDialect dialect, SqlWriterConfig config) {
+    this(config.withDialect(Objects.requireNonNull(dialect)));
+  }
+
+  @Deprecated
   public SqlPrettyWriter(
       SqlDialect dialect,
       boolean alwaysUseParentheses,
       PrintWriter pw) {
-    if (pw == null) {
-      pw = new PrintWriter(sw);
-    }
-    this.pw = pw;
-    this.dialect = dialect;
-    this.alwaysUseParentheses = alwaysUseParentheses;
-    resetSettings();
-    reset();
+    // NOTE that 'pw' is ignored; there is no place for it in the new API
+    this(config().withDialect(Objects.requireNonNull(dialect))
+        .withAlwaysUseParentheses(alwaysUseParentheses));
   }
 
+  @Deprecated
   public SqlPrettyWriter(
       SqlDialect dialect,
       boolean alwaysUseParentheses) {
-    this(dialect, alwaysUseParentheses, null);
+    this(config().withDialect(Objects.requireNonNull(dialect))
+        .withAlwaysUseParentheses(alwaysUseParentheses));
   }
 
+  /** Creates a writer with a given dialect, the default configuration
+   * and a private print writer. */
+  @Deprecated
   public SqlPrettyWriter(SqlDialect dialect) {
-    this(dialect, true);
+    this(config().withDialect(Objects.requireNonNull(dialect)));
+  }
+
+  /** Creates a writer with the given configuration,
+   * and a private builder. */
+  public SqlPrettyWriter(@Nonnull SqlWriterConfig config) {
+    this(config, new StringBuilder(), true);
+  }
+
+  /** Creates a writer with the default configuration.
+   *
+   * @see #config() */
+  public SqlPrettyWriter() {
+    this(config());
+  }
+
+  /** Creates a {@link SqlWriterConfig} with Calcite's SQL dialect. */
+  public static SqlWriterConfig config() {
+    return ImmutableBeans.create(SqlWriterConfig.class)
+        .withDialect(CalciteSqlDialect.DEFAULT);
   }
 
   //~ Methods ----------------------------------------------------------------
 
-  /**
-   * Sets whether the WHEN, THEN and ELSE clauses of a CASE expression appear
-   * at the start of a new line. The default is false.
-   */
+  @Deprecated
   public void setCaseClausesOnNewLines(boolean caseClausesOnNewLines) {
-    this.caseClausesOnNewLines = caseClausesOnNewLines;
+    this.config = config.withCaseClausesOnNewLines(caseClausesOnNewLines);
   }
 
-  /**
-   * Sets the sub-query style. Default is
-   * {@link org.apache.calcite.sql.SqlWriter.SubQueryStyle#HYDE}.
-   */
+  @Deprecated
   public void setSubQueryStyle(SubQueryStyle subQueryStyle) {
-    this.subQueryStyle = subQueryStyle;
+    this.config = config.withSubQueryStyle(subQueryStyle);
   }
 
+  @Deprecated
   public void setWindowNewline(boolean windowNewline) {
-    this.windowNewline = windowNewline;
+    this.config = config.withWindowNewline(windowNewline);
   }
 
+  @Deprecated
   public void setWindowDeclListNewline(boolean windowDeclListNewline) {
-    this.windowDeclListNewline = windowDeclListNewline;
+    this.config = config.withWindowDeclListNewline(windowDeclListNewline);
   }
 
+  @Deprecated
   public int getIndentation() {
-    return indentation;
+    return config.indentation();
   }
 
+  @Deprecated
   public boolean isAlwaysUseParentheses() {
-    return alwaysUseParentheses;
+    return config.alwaysUseParentheses();
   }
 
   public boolean inQuery() {
@@ -231,55 +393,49 @@ public class SqlPrettyWriter implements SqlWriter {
         || (frame.frameType == FrameTypeEnum.SETOP);
   }
 
+  @Deprecated
   public boolean isQuoteAllIdentifiers() {
-    return quoteAllIdentifiers;
+    return config.quoteAllIdentifiers();
   }
 
+  @Deprecated
   public boolean isClauseStartsLine() {
-    return clauseStartsLine;
+    return config.clauseStartsLine();
   }
 
+  @Deprecated
   public boolean isSelectListItemsOnSeparateLines() {
-    return selectListItemsOnSeparateLines;
+    return config.selectListItemsOnSeparateLines();
   }
 
+  @Deprecated
   public boolean isWhereListItemsOnSeparateLines() {
-    return whereListItemsOnSeparateLines;
+    return config.whereListItemsOnSeparateLines();
   }
 
+  @Deprecated
   public boolean isSelectListExtraIndentFlag() {
-    return selectListExtraIndentFlag;
+    return config.selectListExtraIndentFlag();
   }
 
+  @Deprecated
   public boolean isKeywordsLowerCase() {
-    return keywordsLowerCase;
+    return config.keywordsLowerCase();
   }
 
+  @Deprecated
   public int getLineLength() {
-    return lineLength;
+    return config.lineLength();
   }
 
   public void resetSettings() {
     reset();
-    indentation = 4;
-    clauseStartsLine = true;
-    selectListItemsOnSeparateLines = false;
-    selectListExtraIndentFlag = true;
-    keywordsLowerCase = false;
-    quoteAllIdentifiers = true;
-    windowDeclListNewline = true;
-    updateSetListNewline = true;
-    windowNewline = false;
-    subQueryStyle = SubQueryStyle.HYDE;
-    alwaysUseParentheses = false;
-    whereListItemsOnSeparateLines = false;
-    lineLength = 0;
-    charCount = 0;
+    config = config();
   }
 
   public void reset() {
-    pw.flush();
-    Unsafe.clear(sw);
+    buf.setLength(0);
+    lineStart = 0;
     dynamicParameters = null;
     setNeedWhitespace(false);
     nextWhitespace = " ";
@@ -295,13 +451,9 @@ public class SqlPrettyWriter implements SqlWriter {
     return bean;
   }
 
-  /**
-   * Sets the number of spaces indentation.
-   *
-   * @see #getIndentation()
-   */
+  @Deprecated
   public void setIndentation(int indentation) {
-    this.indentation = indentation;
+    this.config = config.withIndentation(indentation);
   }
 
   /**
@@ -343,78 +495,47 @@ public class SqlPrettyWriter implements SqlWriter {
     }
   }
 
-  /**
-   * Sets whether a clause (FROM, WHERE, GROUP BY, HAVING, WINDOW, ORDER BY)
-   * starts a new line. Default is true. SELECT is always at the start of a
-   * line.
-   */
+  @Deprecated
   public void setClauseStartsLine(boolean clauseStartsLine) {
-    this.clauseStartsLine = clauseStartsLine;
+    this.config = config.withClauseStartsLine(clauseStartsLine);
   }
 
-  /**
-   * Sets whether each item in a SELECT list, GROUP BY list, or ORDER BY list
-   * is on its own line. Default false.
-   */
+  @Deprecated
   public void setSelectListItemsOnSeparateLines(boolean b) {
-    this.selectListItemsOnSeparateLines = b;
+    this.config = config.withSelectListItemsOnSeparateLines(b);
   }
 
-  /**
-   * Sets whether to use a fix for SELECT list indentations.
-   *
-   * <ul>
-   * <li>If set to "false":
-   *
-   * <blockquote><pre>
-   * SELECT
-   *     A as A
-   *         B as B
-   *         C as C
-   *     D
-   * </pre></blockquote>
-   *
-   * <li>If set to "true":
-   *
-   * <blockquote><pre>
-   * SELECT
-   *     A as A
-   *     B as B
-   *     C as C
-   *     D
-   * </pre></blockquote>
-   * </ul>
-   */
-  public void setSelectListExtraIndentFlag(boolean b) {
-    this.selectListExtraIndentFlag = b;
+  @Deprecated
+  public void setSelectListExtraIndentFlag(boolean selectListExtraIndentFlag) {
+    this.config =
+        config.withSelectListExtraIndentFlag(selectListExtraIndentFlag);
   }
 
-  /**
-   * Sets whether to print keywords (SELECT, AS, etc.) in lower-case. The
-   * default is false: keywords are printed in upper-case.
-   */
-  public void setKeywordsLowerCase(boolean b) {
-    this.keywordsLowerCase = b;
+  @Deprecated
+  public void setKeywordsLowerCase(boolean keywordsLowerCase) {
+    this.config = config.withKeywordsLowerCase(keywordsLowerCase);
   }
 
-  /**
-   * Sets whether to print a newline before each AND or OR (whichever is
-   * higher level) in WHERE clauses. NOTE: <i>Ignored when
-   * alwaysUseParentheses is set to true.</i>
-   */
-
-  public void setWhereListItemsOnSeparateLines(boolean b) {
-    this.whereListItemsOnSeparateLines = b;
+  @Deprecated
+  public void setWhereListItemsOnSeparateLines(
+      boolean whereListItemsOnSeparateLines) {
+    this.config =
+        config.withWhereListItemsOnSeparateLines(whereListItemsOnSeparateLines);
   }
 
-  public void setAlwaysUseParentheses(boolean b) {
-    this.alwaysUseParentheses = b;
+  @Deprecated
+  public void setAlwaysUseParentheses(boolean alwaysUseParentheses) {
+    this.config = config.withAlwaysUseParentheses(alwaysUseParentheses);
   }
 
   public void newlineAndIndent() {
-    pw.println();
-    charCount = 0;
-    indent(currentIndent);
+    newlineAndIndent(currentIndent);
+  }
+
+  public void newlineAndIndent(int indent) {
+    buf.append(NL);
+    lineStart = buf.length();
+    indent(indent);
     setNeedWhitespace(false); // no further whitespace necessary
   }
 
@@ -422,19 +543,12 @@ public class SqlPrettyWriter implements SqlWriter {
     if (indent < 0) {
       throw new IllegalArgumentException("negative indent " + indent);
     }
-    Spaces.append(pw, indent);
-    charCount += indent;
+    Spaces.append(buf, indent);
   }
 
-  /**
-   * Sets whether to quote all identifiers, even those which would be correct
-   * according to the rules of the {@link SqlDialect} if quotation marks were
-   * omitted.
-   *
-   * <p>Default true.
-   */
-  public void setQuoteAllIdentifiers(boolean b) {
-    this.quoteAllIdentifiers = b;
+  @Deprecated
+  public void setQuoteAllIdentifiers(boolean quoteAllIdentifiers) {
+    this.config = config.withQuoteAllIdentifiers(quoteAllIdentifiers);
   }
 
   /**
@@ -454,283 +568,265 @@ public class SqlPrettyWriter implements SqlWriter {
       String keyword,
       String open,
       String close) {
-    int indentation = getIndentation();
-    if (frameType instanceof FrameTypeEnum) {
-      FrameTypeEnum frameTypeEnum = (FrameTypeEnum) frameType;
-
-      switch (frameTypeEnum) {
-      case WINDOW_DECL_LIST:
-      case VALUES:
-        return new FrameImpl(
-            frameType,
-            keyword,
-            open,
-            close,
-            indentation,
-            false,
-            false,
-            indentation,
-            windowDeclListNewline,
-            false,
-            false);
-
-      case UPDATE_SET_LIST:
-        return new FrameImpl(
-            frameType,
-            keyword,
-            open,
-            close,
-            indentation,
-            false,
-            updateSetListNewline,
-            indentation,
-            false,
-            false,
-            false);
-
-      case SELECT_LIST:
-        return new FrameImpl(
-            frameType,
-            keyword,
-            open,
-            close,
-            selectListExtraIndentFlag ? indentation : 0,
-            selectListItemsOnSeparateLines,
-            false,
-            indentation,
-            selectListItemsOnSeparateLines,
-            false,
-            false);
-
-      case ORDER_BY_LIST:
-      case GROUP_BY_LIST:
-        return new FrameImpl(
-            frameType,
-            keyword,
-            open,
-            close,
-            indentation,
-            selectListItemsOnSeparateLines,
-            false,
-            indentation,
-            selectListItemsOnSeparateLines,
-            false,
-            false);
-
-      case SUB_QUERY:
-        switch (subQueryStyle) {
-        case BLACK:
-
-          // Generate, e.g.:
-          //
-          // WHERE foo = bar IN
-          // (   SELECT ...
-          open = Spaces.padRight("(", indentation);
-          return new FrameImpl(
-              frameType,
-              keyword,
-              open,
-              close,
-              0,
-              false,
-              true,
-              indentation,
-              false,
-              false,
-              false) {
-            protected void _before() {
-              newlineAndIndent();
-            }
-          };
-        case HYDE:
-
-          // Generate, e.g.:
-          //
-          // WHERE foo IN (
-          //     SELECT ...
-          return new FrameImpl(
-              frameType,
-              keyword,
-              open,
-              close,
-              0,
-              false,
-              true,
-              0,
-              false,
-              false,
-              false) {
-            protected void _before() {
-              nextWhitespace = NL;
-            }
-          };
-        default:
-          throw Util.unexpected(subQueryStyle);
-        }
-
-      case ORDER_BY:
-      case OFFSET:
-      case FETCH:
-        return new FrameImpl(
-            frameType,
-            keyword,
-            open,
-            close,
-            0,
-            false,
-            true,
-            0,
-            false,
-            false,
-            false);
-
-      case SELECT:
-        return new FrameImpl(
-            frameType,
-            keyword,
-            open,
-            close,
-            indentation,
-            false,
-            isClauseStartsLine(), // newline before FROM, WHERE etc.
-            0, // all clauses appear below SELECT
-            false,
-            false,
-            false);
-
-      case SETOP:
-        return new FrameImpl(
-            frameType,
-            keyword,
-            open,
-            close,
-            indentation,
-            false,
-            isClauseStartsLine(), // newline before UNION, EXCEPT
-            0, // all clauses appear below SELECT
-            isClauseStartsLine(), // newline after UNION, EXCEPT
-            false,
-            false);
-
-      case WINDOW:
-        return new FrameImpl(
-            frameType,
-            keyword,
-            open,
-            close,
-            indentation,
-            false,
-            windowNewline,
-            0,
-            false,
-            false,
-            false);
-
-      case FUN_CALL:
-        setNeedWhitespace(false);
-        return new FrameImpl(
-            frameType,
-            keyword,
-            open,
-            close,
-            indentation,
-            false,
-            false,
-            indentation,
-            false,
-            false,
-            false);
-
-      case IDENTIFIER:
-      case SIMPLE:
-        return new FrameImpl(
-            frameType,
-            keyword,
-            open,
-            close,
-            indentation,
-            false,
-            false,
-            indentation,
-            false,
-            false,
-            false);
-
-      case WHERE_LIST:
-        return new FrameImpl(
-            frameType,
-            keyword,
-            open,
-            close,
-            indentation,
-            false,
-            whereListItemsOnSeparateLines,
-            0,
-            false,
-            false,
-            false);
-
-      case FROM_LIST:
-      case JOIN:
-        return new FrameImpl(
-            frameType,
-            keyword,
-            open,
-            close,
-            indentation,
-            false,
-            isClauseStartsLine(), // newline before UNION, EXCEPT
-            0, // all clauses appear below SELECT
-            isClauseStartsLine(), // newline after UNION, EXCEPT
-            false,
-            false) {
-          protected void sep(boolean printFirst, String sep) {
-            boolean newlineBefore =
-                newlineBeforeSep
-                    && !sep.equals(",");
-            boolean newlineAfter =
-                newlineAfterSep && sep.equals(",");
-            if ((itemCount > 0) || printFirst) {
-              if (newlineBefore && (itemCount > 0)) {
-                pw.println();
-                charCount = 0;
-                indent(currentIndent + sepIndent);
-                setNeedWhitespace(false);
-              }
-              keyword(sep);
-              nextWhitespace = newlineAfter ? NL : " ";
-            }
-            ++itemCount;
-          }
-        };
-      default:
-        // fall through
-      }
-    }
+    final FrameTypeEnum frameTypeEnum =
+        frameType instanceof FrameTypeEnum ? (FrameTypeEnum) frameType
+            : FrameTypeEnum.OTHER;
+    final int indentation = config.indentation();
     boolean newlineAfterOpen = false;
     boolean newlineBeforeSep = false;
+    boolean newlineAfterSep = false;
     boolean newlineBeforeClose = false;
+    int left = column();
     int sepIndent = indentation;
-    if (frameType.getName().equals("CASE")) {
-      if (caseClausesOnNewLines) {
-        newlineAfterOpen = true;
+    int extraIndent = 0;
+    final SqlWriterConfig.LineFolding fold = fold(frameTypeEnum);
+    final boolean newline = fold == SqlWriterConfig.LineFolding.TALL;
+
+    switch (frameTypeEnum) {
+    case SELECT:
+      extraIndent = indentation;
+      newlineAfterOpen = false;
+      newlineBeforeSep = config.clauseStartsLine(); // newline before FROM, WHERE etc.
+      newlineAfterSep = false;
+      sepIndent = 0; // all clauses appear below SELECT
+      break;
+
+    case SETOP:
+      extraIndent = 0;
+      newlineAfterOpen = false;
+      newlineBeforeSep = config.clauseStartsLine(); // newline before UNION, EXCEPT
+      newlineAfterSep = config.clauseStartsLine(); // newline after UNION, EXCEPT
+      sepIndent = 0; // all clauses appear below SELECT
+      break;
+
+    case SELECT_LIST:
+    case FROM_LIST:
+    case JOIN:
+    case GROUP_BY_LIST:
+    case ORDER_BY_LIST:
+    case WINDOW_DECL_LIST:
+    case VALUES:
+      if (config.selectListExtraIndentFlag()) {
+        extraIndent = indentation;
+      }
+      left = frame == null ? 0 : frame.left;
+      newlineAfterOpen = config.clauseEndsLine()
+          && (fold == SqlWriterConfig.LineFolding.TALL
+              || fold == SqlWriterConfig.LineFolding.STEP);
+      newlineBeforeSep = false;
+      newlineAfterSep = newline;
+      if (config.leadingComma() && newline) {
         newlineBeforeSep = true;
-        newlineBeforeClose = true;
-        sepIndent = 0;
+        newlineAfterSep = false;
+        sepIndent = -", ".length();
       }
+      break;
+
+    case WHERE_LIST:
+    case WINDOW:
+      extraIndent = indentation;
+      newlineAfterOpen = newline && config.clauseEndsLine();
+      newlineBeforeSep = newline;
+      sepIndent = 0;
+      newlineAfterSep = false;
+      break;
+
+    case ORDER_BY:
+    case OFFSET:
+    case FETCH:
+      newlineAfterOpen = false;
+      newlineBeforeSep = true;
+      sepIndent = 0;
+      newlineAfterSep = false;
+      break;
+
+    case UPDATE_SET_LIST:
+      extraIndent = indentation;
+      newlineAfterOpen = newline;
+      newlineBeforeSep = false;
+      sepIndent = 0;
+      newlineAfterSep = newline;
+      break;
+
+    case CASE:
+      newlineAfterOpen = newline;
+      newlineBeforeSep = newline;
+      newlineBeforeClose = newline;
+      sepIndent = 0;
+      break;
+    }
+
+    final int chopColumn;
+    final SqlWriterConfig.LineFolding lineFolding;
+    if (config.lineFolding() == null) {
+      lineFolding = SqlWriterConfig.LineFolding.WIDE;
+      chopColumn = -1;
+    } else {
+      lineFolding = config.lineFolding();
+      if (config.foldLength() > 0
+          && (lineFolding == SqlWriterConfig.LineFolding.CHOP
+              || lineFolding == SqlWriterConfig.LineFolding.FOLD
+              || lineFolding == SqlWriterConfig.LineFolding.STEP)) {
+        chopColumn = left + config.foldLength();
+      } else {
+        chopColumn = -1;
+      }
+    }
+
+    switch (frameTypeEnum) {
+    case UPDATE_SET_LIST:
+    case WINDOW_DECL_LIST:
+    case VALUES:
+    case SELECT:
+    case SETOP:
+    case SELECT_LIST:
+    case WHERE_LIST:
+    case ORDER_BY_LIST:
+    case GROUP_BY_LIST:
+    case WINDOW:
+    case ORDER_BY:
+    case OFFSET:
+    case FETCH:
+      return new FrameImpl(frameType, keyword, open, close,
+          left, extraIndent, chopColumn, lineFolding, newlineAfterOpen,
+          newlineBeforeSep, sepIndent, newlineAfterSep, false, false);
+
+    case SUB_QUERY:
+      switch (config.subQueryStyle()) {
+      case BLACK:
+        // Generate, e.g.:
+        //
+        // WHERE foo = bar IN
+        // (   SELECT ...
+        open = Spaces.padRight("(", indentation - 1);
+        return new FrameImpl(frameType, keyword, open, close,
+            left, 0, chopColumn, lineFolding, false, true, indentation,
+            false, false, false) {
+          protected void _before() {
+            newlineAndIndent();
+          }
+        };
+
+      case HYDE:
+        // Generate, e.g.:
+        //
+        // WHERE foo IN (
+        //     SELECT ...
+        return new FrameImpl(frameType, keyword, open, close, left, 0,
+            chopColumn, lineFolding, false, true, 0, false, false, false) {
+          protected void _before() {
+            nextWhitespace = NL;
+          }
+        };
+
+      default:
+        throw Util.unexpected(config.subQueryStyle());
+      }
+
+    case FUN_CALL:
+      setNeedWhitespace(false);
+      return new FrameImpl(frameType, keyword, open, close,
+          left, indentation, chopColumn, lineFolding, false, false,
+          indentation, false, false, false);
+
+    case PARENTHESES:
+      open = "(";
+      close = ")";
+      // fall through
+    case IDENTIFIER:
+    case SIMPLE:
+      return new FrameImpl(frameType, keyword, open, close,
+          left, indentation, chopColumn, lineFolding, false, false,
+          indentation, false, false, false);
+
+    case FROM_LIST:
+    case JOIN:
+      newlineBeforeSep = newline;
+      sepIndent = 0; // all clauses appear below SELECT
+      newlineAfterSep = false;
+      final boolean newlineBeforeComma = config.leadingComma() && newline;
+      final boolean newlineAfterComma = !config.leadingComma() && newline;
+      return new FrameImpl(frameType, keyword, open, close, left,
+          indentation, chopColumn, lineFolding, newlineAfterOpen,
+          newlineBeforeSep, sepIndent, newlineAfterSep, false, false) {
+        protected void sep(boolean printFirst, String sep) {
+          final boolean newlineBeforeSep;
+          final boolean newlineAfterSep;
+          if (sep.equals(",")) {
+            newlineBeforeSep = newlineBeforeComma;
+            newlineAfterSep = newlineAfterComma;
+          } else {
+            newlineBeforeSep = this.newlineBeforeSep;
+            newlineAfterSep = this.newlineAfterSep;
+          }
+          if (itemCount == 0) {
+            if (newlineAfterOpen) {
+              newlineAndIndent(currentIndent);
+            }
+          } else {
+            if (newlineBeforeSep) {
+              newlineAndIndent(currentIndent + sepIndent);
+            }
+          }
+          if ((itemCount > 0) || printFirst) {
+            keyword(sep);
+            nextWhitespace = newlineAfterSep ? NL : " ";
+          }
+          ++itemCount;
+        }
+      };
+
+    default:
+    case OTHER:
+      return new FrameImpl(frameType, keyword, open, close, left, indentation,
+          chopColumn, lineFolding, newlineAfterOpen, newlineBeforeSep,
+          sepIndent, newlineAfterSep, newlineBeforeClose, false);
+    }
+  }
+
+  private SqlWriterConfig.LineFolding fold(FrameTypeEnum frameType) {
+    switch (frameType) {
+    case SELECT_LIST:
+      return f3(config.selectFolding(), config.lineFolding(),
+          config.selectListItemsOnSeparateLines());
+    case GROUP_BY_LIST:
+      return f3(config.groupByFolding(), config.lineFolding(),
+          config.selectListItemsOnSeparateLines());
+    case ORDER_BY_LIST:
+      return f3(config.orderByFolding(), config.lineFolding(),
+          config.selectListItemsOnSeparateLines());
+    case UPDATE_SET_LIST:
+      return f3(config.updateSetFolding(), config.lineFolding(),
+          config.updateSetListNewline());
+    case WHERE_LIST:
+      return f3(config.whereFolding(), config.lineFolding(),
+          config.whereListItemsOnSeparateLines());
+    case WINDOW_DECL_LIST:
+      return f3(config.windowFolding(), config.lineFolding(),
+          config.clauseStartsLine() && config.windowDeclListNewline());
+    case WINDOW:
+      return f3(config.overFolding(), config.lineFolding(),
+          config.windowNewline());
+    case VALUES:
+      return f3(config.valuesFolding(), config.lineFolding(),
+          config.valuesListNewline());
+    case FROM_LIST:
+    case JOIN:
+      return f3(config.fromFolding(), config.lineFolding(),
+          config.caseClausesOnNewLines());
+    case CASE:
+      return f3(null, null, config.caseClausesOnNewLines());
+    default:
+      return SqlWriterConfig.LineFolding.WIDE;
     }
-    return new FrameImpl(
-        frameType,
-        keyword,
-        open,
-        close,
-        indentation,
-        newlineAfterOpen,
-        newlineBeforeSep,
-        sepIndent,
-        false,
-        newlineBeforeClose,
-        false);
+  }
+
+  private SqlWriterConfig.LineFolding f3(SqlWriterConfig.LineFolding folding0,
+      SqlWriterConfig.LineFolding folding1, boolean opt) {
+    return folding0 != null ? folding0
+        : folding1 != null ? folding1
+            : opt ? SqlWriterConfig.LineFolding.TALL
+                : SqlWriterConfig.LineFolding.WIDE;
   }
 
   /**
@@ -749,16 +845,19 @@ public class SqlPrettyWriter implements SqlWriter {
       String close) {
     assert frameType != null;
     if (frame != null) {
-      ++frame.itemCount;
+      if (frame.itemCount++ == 0 && frame.newlineAfterOpen) {
+        newlineAndIndent();
+      } else if (frameType == FrameTypeEnum.SUB_QUERY
+          && config.subQueryStyle() == SubQueryStyle.BLACK) {
+        newlineAndIndent(currentIndent - frame.extraIndent);
+      }
 
       // REVIEW jvs 9-June-2006:  This is part of the fix for FRG-149
       // (extra frame for identifier was leading to extra indentation,
       // causing select list to come out raggedy with identifiers
       // deeper than literals); are there other frame types
       // for which extra indent should be suppressed?
-      if (frameType.needsIndent()) {
-        currentIndent += frame.extraIndent;
-      }
+      currentIndent += frame.extraIndent(frameType);
       assert !listStack.contains(frame);
       listStack.push(frame);
     }
@@ -793,9 +892,7 @@ public class SqlPrettyWriter implements SqlWriter {
       assert currentIndent == 0 : currentIndent;
     } else {
       this.frame = listStack.pop();
-      if (endedFrame.frameType.needsIndent()) {
-        currentIndent -= this.frame.extraIndent;
-      }
+      currentIndent -= this.frame.extraIndent(endedFrame.frameType);
     }
   }
 
@@ -807,8 +904,7 @@ public class SqlPrettyWriter implements SqlWriter {
   }
 
   public String toString() {
-    pw.flush();
-    return sw.toString();
+    return buf.toString();
   }
 
   public SqlString toSqlString() {
@@ -828,11 +924,10 @@ public class SqlPrettyWriter implements SqlWriter {
 
   public void keyword(String s) {
     maybeWhitespace(s);
-    pw.print(
+    buf.append(
         isKeywordsLowerCase()
             ? s.toLowerCase(Locale.ROOT)
             : s.toUpperCase(Locale.ROOT));
-    charCount += s.length();
     if (!s.equals("")) {
       setNeedWhitespace(needWhitespaceAfter(s));
     }
@@ -864,19 +959,24 @@ public class SqlPrettyWriter implements SqlWriter {
       if (nextWhitespace.equals(NL)) {
         newlineAndIndent();
       } else {
-        pw.print(nextWhitespace);
-        charCount += nextWhitespace.length();
+        buf.append(nextWhitespace);
       }
       nextWhitespace = " ";
       setNeedWhitespace(false);
     }
   }
 
+  /** Returns the number of characters appended since the last newline. */
+  private int column() {
+    return buf.length() - lineStart;
+  }
+
   protected boolean tooLong(String s) {
+    final int lineLength = config.lineLength();
     boolean result =
         lineLength > 0
-            && (charCount > currentIndent)
-            && ((charCount + s.length()) >= lineLength);
+            && (column() > currentIndent)
+            && ((column() + s.length()) >= lineLength);
     if (result) {
       nextWhitespace = NL;
     }
@@ -886,14 +986,12 @@ public class SqlPrettyWriter implements SqlWriter {
 
   public void print(String s) {
     maybeWhitespace(s);
-    pw.print(s);
-    charCount += s.length();
+    buf.append(s);
   }
 
   public void print(int x) {
     maybeWhitespace("0");
-    pw.print(x);
-    charCount += String.valueOf(x).length();
+    buf.append(x);
   }
 
   public void identifier(String name, boolean quoted) {
@@ -904,8 +1002,7 @@ public class SqlPrettyWriter implements SqlWriter {
       qName = dialect.quoteIdentifier(name);
     }
     maybeWhitespace(qName);
-    pw.print(qName);
-    charCount += qName.length();
+    buf.append(qName);
     setNeedWhitespace(true);
   }
 
@@ -956,6 +1053,23 @@ public class SqlPrettyWriter implements SqlWriter {
     return startList(frameType, null, open, close);
   }
 
+  public SqlWriter list(FrameTypeEnum frameType, Consumer<SqlWriter> action) {
+    final SqlWriter.Frame selectListFrame =
+        startList(SqlWriter.FrameTypeEnum.SELECT_LIST);
+    final SqlWriter w = this;
+    action.accept(w);
+    endList(selectListFrame);
+    return this;
+  }
+
+  public SqlWriter list(FrameTypeEnum frameType, SqlBinaryOperator sepOp,
+      SqlNodeList list) {
+    final SqlWriter.Frame frame = startList(frameType);
+    ((FrameImpl) frame).list(list, sepOp);
+    endList(frame);
+    return this;
+  }
+
   public void sep(String sep) {
     sep(sep, !(sep.equals(",") || sep.equals(".")));
   }
@@ -974,8 +1088,9 @@ public class SqlPrettyWriter implements SqlWriter {
     this.needWhitespace = needWhitespace;
   }
 
+  @Deprecated
   public void setLineLength(int lineLength) {
-    this.lineLength = lineLength;
+    this.config = config.withLineLength(lineLength);
   }
 
   public void setFormatOptions(SqlFormatOptions options) {
@@ -1008,6 +1123,7 @@ public class SqlPrettyWriter implements SqlWriter {
     final String open;
     final String close;
 
+    private final int left;
     /**
      * Indent of sub-frame with respect to this one.
      */
@@ -1033,33 +1149,40 @@ public class SqlPrettyWriter implements SqlWriter {
      * Whether to print a newline after each separator.
      */
     public final boolean newlineAfterSep;
-    private final boolean newlineBeforeClose;
-    private final boolean newlineAfterClose;
-    private final boolean newlineAfterOpen;
-
-    FrameImpl(
-        FrameType frameType,
-        String keyword,
-        String open,
-        String close,
-        int extraIndent,
-        boolean newlineAfterOpen,
-        boolean newlineBeforeSep,
-        int sepIndent,
-        boolean newlineAfterSep,
-        boolean newlineBeforeClose,
-        boolean newlineAfterClose) {
+    protected final boolean newlineBeforeClose;
+    protected final boolean newlineAfterClose;
+    protected final boolean newlineAfterOpen;
+
+    /** Character count after which we should move an item to the
+     * next line. Or {@link Integer#MAX_VALUE} if we are not chopping. */
+    private final int chopLimit;
+
+    /** How lines are to be folded. */
+    private final SqlWriterConfig.LineFolding lineFolding;
+
+    FrameImpl(FrameType frameType, String keyword, String open, String close,
+        int left, int extraIndent, int chopLimit,
+        SqlWriterConfig.LineFolding lineFolding, boolean newlineAfterOpen,
+        boolean newlineBeforeSep, int sepIndent, boolean newlineAfterSep,
+        boolean newlineBeforeClose, boolean newlineAfterClose) {
       this.frameType = frameType;
       this.keyword = keyword;
       this.open = open;
       this.close = close;
+      this.left = left;
       this.extraIndent = extraIndent;
+      this.chopLimit = chopLimit;
+      this.lineFolding = lineFolding;
       this.newlineAfterOpen = newlineAfterOpen;
       this.newlineBeforeSep = newlineBeforeSep;
       this.newlineAfterSep = newlineAfterSep;
       this.newlineBeforeClose = newlineBeforeClose;
       this.newlineAfterClose = newlineAfterClose;
       this.sepIndent = sepIndent;
+      assert chopLimit >= 0
+          == (lineFolding == SqlWriterConfig.LineFolding.CHOP
+          || lineFolding == SqlWriterConfig.LineFolding.FOLD
+          || lineFolding == SqlWriterConfig.LineFolding.STEP);
     }
 
     protected void before() {
@@ -1072,9 +1195,14 @@ public class SqlPrettyWriter implements SqlWriter {
     }
 
     protected void sep(boolean printFirst, String sep) {
-      if ((newlineBeforeSep && (itemCount > 0))
-          || (newlineAfterOpen && (itemCount == 0))) {
-        newlineAndIndent();
+      if (itemCount == 0) {
+        if (newlineAfterOpen) {
+          newlineAndIndent(currentIndent);
+        }
+      } else {
+        if (newlineBeforeSep) {
+          newlineAndIndent(currentIndent + sepIndent);
+        }
       }
       if ((itemCount > 0) || printFirst) {
         keyword(sep);
@@ -1082,6 +1210,150 @@ public class SqlPrettyWriter implements SqlWriter {
       }
       ++itemCount;
     }
+
+    /** Returns the extra indent required for a given type of sub-frame. */
+    int extraIndent(FrameType subFrameType) {
+      if (this.frameType == FrameTypeEnum.ORDER_BY
+          && subFrameType == FrameTypeEnum.ORDER_BY_LIST) {
+        return config.indentation();
+      }
+      if (subFrameType.needsIndent()) {
+        return extraIndent;
+      }
+      return 0;
+    }
+
+    void list(SqlNodeList list, SqlBinaryOperator sepOp) {
+      final Save save;
+      switch (lineFolding) {
+      case CHOP:
+      case FOLD:
+        save = new Save();
+        if (!list2(list, sepOp)) {
+          save.restore();
+          final boolean newlineAfterOpen = config.clauseEndsLine();
+          final SqlWriterConfig.LineFolding lineFolding;
+          final int chopLimit;
+          if (this.lineFolding == SqlWriterConfig.LineFolding.CHOP) {
+            lineFolding = SqlWriterConfig.LineFolding.TALL;
+            chopLimit = -1;
+          } else {
+            lineFolding = SqlWriterConfig.LineFolding.FOLD;
+            chopLimit = this.chopLimit;
+          }
+          final boolean newline =
+              lineFolding == SqlWriterConfig.LineFolding.TALL;
+          final boolean newlineBeforeSep;
+          final boolean newlineAfterSep;
+          final int sepIndent;
+          if (config.leadingComma() && newline) {
+            newlineBeforeSep = true;
+            newlineAfterSep = false;
+            sepIndent = -", ".length();
+          } else if (newline) {
+            newlineBeforeSep = false;
+            newlineAfterSep = true;
+            sepIndent = this.sepIndent;
+          } else {
+            newlineBeforeSep = false;
+            newlineAfterSep = false;
+            sepIndent = this.sepIndent;
+          }
+          final FrameImpl frame2 =
+              new FrameImpl(frameType, keyword, open, close, left, extraIndent,
+                  chopLimit, lineFolding, newlineAfterOpen, newlineBeforeSep,
+                  sepIndent, newlineAfterSep, newlineBeforeClose,
+                  newlineAfterClose);
+          frame2.list2(list, sepOp);
+        }
+        break;
+      default:
+        list2(list, sepOp);
+      }
+    }
+
+    /** Tries to write a list. If the line runs too long, returns false,
+     * indicating to retry. */
+    private boolean list2(SqlNodeList list, SqlBinaryOperator sepOp) {
+      // The precedence pulling on the LHS of a node is the
+      // right-precedence of the separator operator. Similarly RHS.
+      //
+      // At the start and end of the list precedence should be 0, but non-zero
+      // precedence is useful, because it forces parentheses around
+      // sub-queries and empty lists, e.g. "SELECT a, (SELECT * FROM t), b",
+      // "GROUP BY ()".
+      final int lprec = sepOp.getRightPrec();
+      final int rprec = sepOp.getLeftPrec();
+      if (chopLimit < 0) {
+        for (int i = 0; i < list.size(); i++) {
+          SqlNode node = list.get(i);
+          sep(false, sepOp.getName());
+          node.unparse(SqlPrettyWriter.this, lprec, rprec);
+        }
+      } else if (newlineBeforeSep) {
+        for (int i = 0; i < list.size(); i++) {
+          SqlNode node = list.get(i);
+          sep(false, sepOp.getName());
+          final Save prevSize = new Save();
+          node.unparse(SqlPrettyWriter.this, lprec, rprec);
+          if (column() > chopLimit) {
+            if (lineFolding == SqlWriterConfig.LineFolding.CHOP
+                || lineFolding == SqlWriterConfig.LineFolding.TALL) {
+              return false;
+            }
+            prevSize.restore();
+            newlineAndIndent();
+            node.unparse(SqlPrettyWriter.this, lprec, rprec);
+          }
+        }
+      } else {
+        for (int i = 0; i < list.size(); i++) {
+          SqlNode node = list.get(i);
+          if (i == 0) {
+            sep(false, sepOp.getName());
+          }
+          final Save save = new Save();
+          node.unparse(SqlPrettyWriter.this, lprec, rprec);
+          if (i + 1 < list.size()) {
+            sep(false, sepOp.getName());
+          }
+          if (column() > chopLimit) {
+            switch (lineFolding) {
+            case CHOP:
+              return false;
+            case FOLD:
+              if (newlineAfterOpen != config.clauseEndsLine()) {
+                return false;
+              }
+            }
+            save.restore();
+            newlineAndIndent();
+            node.unparse(SqlPrettyWriter.this, lprec, rprec);
+            if (i + 1 < list.size()) {
+              sep(false, sepOp.getName());
+            }
+          }
+        }
+      }
+      return true;
+    }
+
+    /** Remembers the state of the current frame and writer.
+     *
+     * <p>You can call {@link #restore} to restore to that state, or just
+     * continue. It is useful if you wish to re-try with different options
+     * (for example, with lines wrapped). */
+    class Save {
+      final int bufLength;
+
+      Save() {
+        this.bufLength = buf.length();
+      }
+
+      void restore() {
+        buf.setLength(bufLength);
+      }
+    }
   }
 
   /**
@@ -1160,4 +1432,5 @@ public class SqlPrettyWriter implements SqlWriter {
       return names.toArray(new String[0]);
     }
   }
+
 }
diff --git a/core/src/main/java/org/apache/calcite/sql/type/IntervalSqlType.java b/core/src/main/java/org/apache/calcite/sql/type/IntervalSqlType.java
index f427a31..c58240c 100644
--- a/core/src/main/java/org/apache/calcite/sql/type/IntervalSqlType.java
+++ b/core/src/main/java/org/apache/calcite/sql/type/IntervalSqlType.java
@@ -22,6 +22,7 @@ import org.apache.calcite.rel.type.RelDataTypeFactoryImpl;
 import org.apache.calcite.rel.type.RelDataTypeSystem;
 import org.apache.calcite.sql.SqlDialect;
 import org.apache.calcite.sql.SqlIntervalQualifier;
+import org.apache.calcite.sql.SqlWriterConfig;
 import org.apache.calcite.sql.dialect.AnsiSqlDialect;
 import org.apache.calcite.sql.parser.SqlParserPos;
 import org.apache.calcite.sql.pretty.SqlPrettyWriter;
@@ -58,10 +59,12 @@ public class IntervalSqlType extends AbstractSqlType {
   protected void generateTypeString(StringBuilder sb, boolean withDetail) {
     sb.append("INTERVAL ");
     final SqlDialect dialect = AnsiSqlDialect.DEFAULT;
-    final SqlPrettyWriter writer = new SqlPrettyWriter(dialect);
-    writer.setAlwaysUseParentheses(false);
-    writer.setSelectListItemsOnSeparateLines(false);
-    writer.setIndentation(0);
+    final SqlWriterConfig config = SqlPrettyWriter.config()
+        .withAlwaysUseParentheses(false)
+        .withSelectListItemsOnSeparateLines(false)
+        .withIndentation(0)
+        .withDialect(dialect);
+    final SqlPrettyWriter writer = new SqlPrettyWriter(config);
     intervalQualifier.unparse(writer, 0, 0);
     final String sql = writer.toString();
     sb.append(new SqlString(dialect, sql).getSql());
diff --git a/core/src/test/java/org/apache/calcite/sql/parser/SqlParserTest.java b/core/src/test/java/org/apache/calcite/sql/parser/SqlParserTest.java
index 9331b44..269bcb2 100644
--- a/core/src/test/java/org/apache/calcite/sql/parser/SqlParserTest.java
+++ b/core/src/test/java/org/apache/calcite/sql/parser/SqlParserTest.java
@@ -26,7 +26,8 @@ import org.apache.calcite.sql.SqlKind;
 import org.apache.calcite.sql.SqlNode;
 import org.apache.calcite.sql.SqlNodeList;
 import org.apache.calcite.sql.SqlSetOption;
-import org.apache.calcite.sql.dialect.CalciteSqlDialect;
+import org.apache.calcite.sql.SqlWriterConfig;
+import org.apache.calcite.sql.dialect.AnsiSqlDialect;
 import org.apache.calcite.sql.parser.impl.SqlParserImpl;
 import org.apache.calcite.sql.pretty.SqlPrettyWriter;
 import org.apache.calcite.sql.test.SqlTests;
@@ -59,6 +60,7 @@ import java.util.List;
 import java.util.Locale;
 import java.util.Map;
 import java.util.Objects;
+import java.util.Random;
 import java.util.SortedSet;
 import java.util.TreeSet;
 import java.util.function.Consumer;
@@ -70,6 +72,7 @@ import static org.hamcrest.CoreMatchers.containsString;
 import static org.hamcrest.CoreMatchers.equalTo;
 import static org.hamcrest.CoreMatchers.is;
 import static org.hamcrest.CoreMatchers.not;
+import static org.hamcrest.CoreMatchers.notNullValue;
 import static org.hamcrest.MatcherAssert.assertThat;
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assumptions.assumeFalse;
@@ -566,6 +569,13 @@ public class SqlParserTest {
   private static final ThreadLocal<boolean[]> LINUXIFY =
       ThreadLocal.withInitial(() -> new boolean[] {true});
 
+  private static final SqlWriterConfig SQL_WRITER_CONFIG =
+      SqlPrettyWriter.config()
+          .withAlwaysUseParentheses(true)
+          .withUpdateSetListNewline(false)
+          .withFromFolding(SqlWriterConfig.LineFolding.TALL)
+          .withIndentation(0);
+
   Quoting quoting = Quoting.DOUBLE_QUOTE;
   Casing unquotedCasing = Casing.TO_UPPER;
   Casing quotedCasing = Casing.UNCHANGED;
@@ -3768,10 +3778,10 @@ public class SqlParserTest {
 
   @Test public void testUpdateExtendedColumnList() {
     final String expected = "UPDATE `EMPDEFAULTS` EXTEND (`EXTRA` BOOLEAN, `NOTE` VARCHAR)"
-        + " SET `DEPTNO` = 1\n"
-        + ", `EXTRA` = TRUE\n"
-        + ", `EMPNO` = 20\n"
-        + ", `ENAME` = 'Bob'\n"
+        + " SET `DEPTNO` = 1"
+        + ", `EXTRA` = TRUE"
+        + ", `EMPNO` = 20"
+        + ", `ENAME` = 'Bob'"
         + ", `NOTE` = 'legion'\n"
         + "WHERE (`DEPTNO` = 10)";
     sql("update empdefaults(extra BOOLEAN, note VARCHAR)"
@@ -3783,10 +3793,10 @@ public class SqlParserTest {
 
   @Test public void testUpdateCaseSensitiveExtendedColumnList() {
     final String expected = "UPDATE `EMPDEFAULTS` EXTEND (`extra` BOOLEAN, `NOTE` VARCHAR)"
-        + " SET `DEPTNO` = 1\n"
-        + ", `extra` = TRUE\n"
-        + ", `EMPNO` = 20\n"
-        + ", `ENAME` = 'Bob'\n"
+        + " SET `DEPTNO` = 1"
+        + ", `extra` = TRUE"
+        + ", `EMPNO` = 20"
+        + ", `ENAME` = 'Bob'"
         + ", `NOTE` = 'legion'\n"
         + "WHERE (`DEPTNO` = 10)";
     sql("update empdefaults(\"extra\" BOOLEAN, note VARCHAR)"
@@ -3866,7 +3876,7 @@ public class SqlParserTest {
 
   @Test public void testUpdate() {
     sql("update emps set empno = empno + 1, sal = sal - 1 where empno=12")
-        .ok("UPDATE `EMPS` SET `EMPNO` = (`EMPNO` + 1)\n"
+        .ok("UPDATE `EMPS` SET `EMPNO` = (`EMPNO` + 1)"
             + ", `SAL` = (`SAL` - 1)\n"
             + "WHERE (`EMPNO` = 12)");
   }
@@ -3884,8 +3894,8 @@ public class SqlParserTest {
         + "FROM `TEMPEMPS`\n"
         + "WHERE (`DEPTNO` IS NULL)) AS `T`\n"
         + "ON (`E`.`EMPNO` = `T`.`EMPNO`)\n"
-        + "WHEN MATCHED THEN UPDATE SET `NAME` = `T`.`NAME`\n"
-        + ", `DEPTNO` = `T`.`DEPTNO`\n"
+        + "WHEN MATCHED THEN UPDATE SET `NAME` = `T`.`NAME`"
+        + ", `DEPTNO` = `T`.`DEPTNO`"
         + ", `SALARY` = (`T`.`SALARY` * 0.1)\n"
         + "WHEN NOT MATCHED THEN INSERT (`NAME`, `DEPT`, `SALARY`) "
         + "(VALUES (ROW(`T`.`NAME`, 10, (`T`.`SALARY` * 0.15))))";
@@ -3907,8 +3917,8 @@ public class SqlParserTest {
         + "FROM `TEMPEMPS`\n"
         + "WHERE (`DEPTNO` IS NULL)) AS `T`\n"
         + "ON (`E`.`EMPNO` = `T`.`EMPNO`)\n"
-        + "WHEN MATCHED THEN UPDATE SET `E`.`NAME` = `T`.`NAME`\n"
-        + ", `E`.`DEPTNO` = `T`.`DEPTNO`\n"
+        + "WHEN MATCHED THEN UPDATE SET `E`.`NAME` = `T`.`NAME`"
+        + ", `E`.`DEPTNO` = `T`.`DEPTNO`"
         + ", `E`.`SALARY` = (`T`.`SALARY` * 0.1)\n"
         + "WHEN NOT MATCHED THEN INSERT (`NAME`, `DEPT`, `SALARY`) "
         + "(VALUES (ROW(`T`.`NAME`, 10, (`T`.`SALARY` * 0.15))))";
@@ -3927,8 +3937,8 @@ public class SqlParserTest {
     final String expected = "MERGE INTO `EMPS` AS `E`\n"
         + "USING `TEMPEMPS` AS `T`\n"
         + "ON (`E`.`EMPNO` = `T`.`EMPNO`)\n"
-        + "WHEN MATCHED THEN UPDATE SET `NAME` = `T`.`NAME`\n"
-        + ", `DEPTNO` = `T`.`DEPTNO`\n"
+        + "WHEN MATCHED THEN UPDATE SET `NAME` = `T`.`NAME`"
+        + ", `DEPTNO` = `T`.`DEPTNO`"
         + ", `SALARY` = (`T`.`SALARY` * 0.1)\n"
         + "WHEN NOT MATCHED THEN INSERT (`NAME`, `DEPT`, `SALARY`) "
         + "(VALUES (ROW(`T`.`NAME`, 10, (`T`.`SALARY` * 0.15))))";
@@ -3947,8 +3957,8 @@ public class SqlParserTest {
     final String expected = "MERGE INTO `EMPS` AS `E`\n"
         + "USING `TEMPEMPS` AS `T`\n"
         + "ON (`E`.`EMPNO` = `T`.`EMPNO`)\n"
-        + "WHEN MATCHED THEN UPDATE SET `E`.`NAME` = `T`.`NAME`\n"
-        + ", `E`.`DEPTNO` = `T`.`DEPTNO`\n"
+        + "WHEN MATCHED THEN UPDATE SET `E`.`NAME` = `T`.`NAME`"
+        + ", `E`.`DEPTNO` = `T`.`DEPTNO`"
         + ", `E`.`SALARY` = (`T`.`SALARY` * 0.1)\n"
         + "WHEN NOT MATCHED THEN INSERT (`NAME`, `DEPT`, `SALARY`) "
         + "(VALUES (ROW(`T`.`NAME`, 10, (`T`.`SALARY` * 0.15))))";
@@ -7461,11 +7471,11 @@ public class SqlParserTest {
     SqlNode node = getSqlParser("alter system set schema = true").parseStmt();
     SqlSetOption opt = (SqlSetOption) node;
     assertThat(opt.getScope(), equalTo("SYSTEM"));
-    SqlPrettyWriter writer = new SqlPrettyWriter(CalciteSqlDialect.DEFAULT);
+    SqlPrettyWriter writer = new SqlPrettyWriter();
     assertThat(writer.format(opt.getName()), equalTo("\"SCHEMA\""));
-    writer = new SqlPrettyWriter(CalciteSqlDialect.DEFAULT);
+    writer = new SqlPrettyWriter();
     assertThat(writer.format(opt.getValue()), equalTo("TRUE"));
-    writer = new SqlPrettyWriter(CalciteSqlDialect.DEFAULT);
+    writer = new SqlPrettyWriter();
     assertThat(writer.format(opt),
         equalTo("ALTER SYSTEM SET \"SCHEMA\" = TRUE"));
 
@@ -7493,10 +7503,10 @@ public class SqlParserTest {
     node = getSqlParser("reset schema").parseStmt();
     opt = (SqlSetOption) node;
     assertThat(opt.getScope(), equalTo(null));
-    writer = new SqlPrettyWriter(CalciteSqlDialect.DEFAULT);
+    writer = new SqlPrettyWriter();
     assertThat(writer.format(opt.getName()), equalTo("\"SCHEMA\""));
     assertThat(opt.getValue(), equalTo(null));
-    writer = new SqlPrettyWriter(CalciteSqlDialect.DEFAULT);
+    writer = new SqlPrettyWriter();
     assertThat(writer.format(opt),
         equalTo("RESET \"SCHEMA\""));
 
@@ -8716,7 +8726,7 @@ public class SqlParserTest {
     final String expected = "UPDATE `EMPS`\n"
         + "/*+ `PROPERTIES`(`K1` ='v1', `K2` ='v2'), "
         + "`INDEX`(`IDX1`, `IDX2`), `NO_HASH_JOIN` */ "
-        + "SET `EMPNO` = (`EMPNO` + 1)\n"
+        + "SET `EMPNO` = (`EMPNO` + 1)"
         + ", `SAL` = (`SAL` - 1)\n"
         + "WHERE (`EMPNO` = 12)";
     sql(sql).ok(expected);
@@ -8737,8 +8747,8 @@ public class SqlParserTest {
         + "AS `E`\n"
         + "USING `TEMPEMPS` AS `T`\n"
         + "ON (`E`.`EMPNO` = `T`.`EMPNO`)\n"
-        + "WHEN MATCHED THEN UPDATE SET `NAME` = `T`.`NAME`\n"
-        + ", `DEPTNO` = `T`.`DEPTNO`\n"
+        + "WHEN MATCHED THEN UPDATE SET `NAME` = `T`.`NAME`"
+        + ", `DEPTNO` = `T`.`DEPTNO`"
         + ", `SALARY` = (`T`.`SALARY` * 0.1)\n"
         + "WHEN NOT MATCHED THEN INSERT (`NAME`, `DEPT`, `SALARY`) "
         + "(VALUES (ROW(`T`.`NAME`, 10, (`T`.`SALARY` * 0.15))))";
@@ -8796,8 +8806,9 @@ public class SqlParserTest {
         SqlNode sqlNode,
         SqlDialect dialect,
         String expected) {
-      // no dialect, always parenthesize
-      final String actual = sqlNode.toSqlString(dialect, true).getSql();
+      final SqlDialect dialect2 = Util.first(dialect, AnsiSqlDialect.DEFAULT);
+      final SqlWriterConfig c2 = SQL_WRITER_CONFIG.withDialect(dialect2);
+      final String actual = sqlNode.toSqlString(c -> c2).getSql();
       TestUtil.assertEqualsVerbose(expected, linux(actual));
     }
 
@@ -8933,21 +8944,64 @@ public class SqlParserTest {
    * unparsing a query are consistent with the original query.
    */
   public class UnparsingTesterImpl extends TesterImpl {
+    private UnaryOperator<SqlWriterConfig> simple() {
+      return c -> c.withSelectListItemsOnSeparateLines(false)
+          .withUpdateSetListNewline(false)
+          .withIndentation(0)
+          .withFromFolding(SqlWriterConfig.LineFolding.TALL);
+    }
+
+    private UnaryOperator<SqlWriterConfig> simpleWithParens() {
+      return simple().andThen(withParens())::apply;
+    }
+
+    private UnaryOperator<SqlWriterConfig> simpleWithParensAnsi() {
+      return simpleWithParens().andThen(withAnsi())::apply;
+    }
+
+    private UnaryOperator<SqlWriterConfig> withParens() {
+      return c -> c.withAlwaysUseParentheses(true);
+    }
+
+    private UnaryOperator<SqlWriterConfig> withAnsi() {
+      return c -> c.withDialect(AnsiSqlDialect.DEFAULT);
+    }
+
+    private UnaryOperator<SqlWriterConfig> randomize(Random random) {
+      return c -> c.withFoldLength(random.nextInt(5) * 20 + 3)
+          .withHavingFolding(nextLineFolding(random))
+          .withWhereFolding(nextLineFolding(random))
+          .withSelectFolding(nextLineFolding(random))
+          .withFromFolding(nextLineFolding(random))
+          .withGroupByFolding(nextLineFolding(random))
+          .withClauseStartsLine(random.nextBoolean())
+          .withClauseEndsLine(random.nextBoolean());
+    }
+
+    private String toSqlString(SqlNodeList sqlNodeList,
+        UnaryOperator<SqlWriterConfig> transform) {
+      return sqlNodeList.getList().stream()
+          .map(node -> node.toSqlString(transform).getSql())
+          .collect(Collectors.joining(";"));
+    }
 
-    private String toSqlString(SqlNodeList sqlNodeList) {
-      List<String> sqls = sqlNodeList.getList().stream()
-          .map(it -> it.toSqlString(CalciteSqlDialect.DEFAULT, false).getSql())
-          .collect(Collectors.toList());
-      return String.join(";", sqls);
+    private SqlWriterConfig.LineFolding nextLineFolding(Random random) {
+      return nextEnum(random, SqlWriterConfig.LineFolding.class);
+    }
+
+    private <E extends Enum<E>> E nextEnum(Random random, Class<E> enumClass) {
+      final E[] constants = enumClass.getEnumConstants();
+      return constants[random.nextInt(constants.length)];
     }
 
     private void checkList(SqlNodeList sqlNodeList, List<String> expected) {
-      assertEquals(expected.size(), sqlNodeList.size());
+      assertThat(sqlNodeList.size(), is(expected.size()));
 
       for (int i = 0; i < sqlNodeList.size(); i++) {
         SqlNode sqlNode = sqlNodeList.get(i);
         // Unparse with no dialect, always parenthesize.
-        final String actual = sqlNode.toSqlString(null, true).getSql();
+        final String actual =
+            sqlNode.toSqlString(simpleWithParensAnsi()).getSql();
         assertEquals(expected.get(i), linux(actual));
       }
     }
@@ -8959,7 +9013,7 @@ public class SqlParserTest {
 
       // Unparse again in Calcite dialect (which we can parse), and
       // minimal parentheses.
-      final String sql1 = toSqlString(sqlNodeList);
+      final String sql1 = toSqlString(sqlNodeList, simple());
 
       // Parse and unparse again.
       SqlNodeList sqlNodeList2;
@@ -8970,7 +9024,7 @@ public class SqlParserTest {
       } finally {
         quoting = q;
       }
-      final String sql2 = toSqlString(sqlNodeList2);
+      final String sql2 = toSqlString(sqlNodeList2, simple());
 
       // Should be the same as we started with.
       assertEquals(sql1, sql2);
@@ -8979,6 +9033,10 @@ public class SqlParserTest {
       // If the unparser is not including sufficient parens to override
       // precedence, the problem will show up here.
       checkList(sqlNodeList2, expected);
+
+      final Random random = new Random();
+      final String sql3 = toSqlString(sqlNodeList, randomize(random));
+      assertThat(sql3, notNullValue());
     }
 
     @Override public void check(String sql, SqlDialect dialect, String expected,
@@ -8988,13 +9046,15 @@ public class SqlParserTest {
           parserChecker);
 
       // Unparse with the given dialect, always parenthesize.
-      final String actual = sqlNode.toSqlString(dialect, true).getSql();
+      final SqlDialect dialect2 = Util.first(dialect, AnsiSqlDialect.DEFAULT);
+      final UnaryOperator<SqlWriterConfig> transform =
+          simpleWithParens().andThen(c -> c.withDialect(dialect2))::apply;
+      final String actual = sqlNode.toSqlString(transform).getSql();
       assertEquals(expected, linux(actual));
 
       // Unparse again in Calcite dialect (which we can parse), and
       // minimal parentheses.
-      final String sql1 =
-          sqlNode.toSqlString(CalciteSqlDialect.DEFAULT, false).getSql();
+      final String sql1 = sqlNode.toSqlString(simple()).getSql();
 
       // Parse and unparse again.
       SqlNode sqlNode2;
@@ -9005,8 +9065,7 @@ public class SqlParserTest {
       } finally {
         quoting = q;
       }
-      final String sql2 =
-          sqlNode2.toSqlString(CalciteSqlDialect.DEFAULT, false).getSql();
+      final String sql2 = sqlNode2.toSqlString(simple()).getSql();
 
       // Should be the same as we started with.
       assertEquals(sql1, sql2);
@@ -9014,8 +9073,23 @@ public class SqlParserTest {
       // Now unparse again in the given dialect.
       // If the unparser is not including sufficient parens to override
       // precedence, the problem will show up here.
-      final String actual2 = sqlNode2.toSqlString(dialect, true).getSql();
+      final String actual2 = sqlNode.toSqlString(transform).getSql();
       assertEquals(expected, linux(actual2));
+
+      // Now unparse with a randomly configured SqlPrettyWriter.
+      // (This is a much a test for SqlPrettyWriter as for the parser.)
+      final Random random = new Random();
+      final String sql3 = sqlNode.toSqlString(randomize(random)).getSql();
+      assertThat(sql3, notNullValue());
+      SqlNode sqlNode4;
+      try {
+        quoting = Quoting.DOUBLE_QUOTE;
+        sqlNode4 = parseStmtAndHandleEx(sql1, b -> b, parser -> { });
+      } finally {
+        quoting = q;
+      }
+      final String sql4 = sqlNode4.toSqlString(simple()).getSql();
+      assertEquals(sql1, sql4);
     }
 
     @Override public void checkExp(String sql, String expected,
@@ -9023,13 +9097,15 @@ public class SqlParserTest {
       SqlNode sqlNode = parseExpressionAndHandleEx(sql, parserChecker);
 
       // Unparse with no dialect, always parenthesize.
-      final String actual = sqlNode.toSqlString(null, true).getSql();
+      final UnaryOperator<SqlWriterConfig> transform = c ->
+          simpleWithParens().apply(c).withDialect(AnsiSqlDialect.DEFAULT);
+      final String actual = sqlNode.toSqlString(transform).getSql();
       assertEquals(expected, linux(actual));
 
       // Unparse again in Calcite dialect (which we can parse), and
       // minimal parentheses.
       final String sql1 =
-          sqlNode.toSqlString(CalciteSqlDialect.DEFAULT, false).getSql();
+          sqlNode.toSqlString(UnaryOperator.identity()).getSql();
 
       // Parse and unparse again.
       // (Turn off parser checking, and use double-quotes.)
@@ -9042,7 +9118,7 @@ public class SqlParserTest {
         quoting = q;
       }
       final String sql2 =
-          sqlNode2.toSqlString(CalciteSqlDialect.DEFAULT, false).getSql();
+          sqlNode2.toSqlString(UnaryOperator.identity()).getSql();
 
       // Should be the same as we started with.
       assertEquals(sql1, sql2);
diff --git a/core/src/test/java/org/apache/calcite/sql/test/SqlOperatorBaseTest.java b/core/src/test/java/org/apache/calcite/sql/test/SqlOperatorBaseTest.java
index 6559238..4300ba4 100644
--- a/core/src/test/java/org/apache/calcite/sql/test/SqlOperatorBaseTest.java
+++ b/core/src/test/java/org/apache/calcite/sql/test/SqlOperatorBaseTest.java
@@ -36,7 +36,6 @@ import org.apache.calcite.sql.SqlNodeList;
 import org.apache.calcite.sql.SqlOperandCountRange;
 import org.apache.calcite.sql.SqlOperator;
 import org.apache.calcite.sql.dialect.AnsiSqlDialect;
-import org.apache.calcite.sql.dialect.CalciteSqlDialect;
 import org.apache.calcite.sql.fun.SqlLibrary;
 import org.apache.calcite.sql.fun.SqlLibraryOperatorTableFactory;
 import org.apache.calcite.sql.fun.SqlLibraryOperators;
@@ -9004,8 +9003,7 @@ public abstract class SqlOperatorBaseTest {
           if (!typeChecker.checkOperandTypes(binding, false)) {
             continue;
           }
-          final SqlPrettyWriter writer =
-              new SqlPrettyWriter(CalciteSqlDialect.DEFAULT);
+          final SqlPrettyWriter writer = new SqlPrettyWriter();
           op.unparse(writer, call, 0, 0);
           final String s = writer.toSqlString().toString();
           if (s.startsWith("OVERLAY(")
diff --git a/core/src/test/java/org/apache/calcite/sql/test/SqlPrettyWriterTest.java b/core/src/test/java/org/apache/calcite/sql/test/SqlPrettyWriterTest.java
index 57137b6..ebe61b2 100644
--- a/core/src/test/java/org/apache/calcite/sql/test/SqlPrettyWriterTest.java
+++ b/core/src/test/java/org/apache/calcite/sql/test/SqlPrettyWriterTest.java
@@ -19,6 +19,7 @@ package org.apache.calcite.sql.test;
 import org.apache.calcite.sql.SqlCall;
 import org.apache.calcite.sql.SqlNode;
 import org.apache.calcite.sql.SqlWriter;
+import org.apache.calcite.sql.SqlWriterConfig;
 import org.apache.calcite.sql.dialect.AnsiSqlDialect;
 import org.apache.calcite.sql.parser.SqlParseException;
 import org.apache.calcite.sql.parser.SqlParser;
@@ -32,7 +33,6 @@ import org.junit.jupiter.api.Test;
 import java.io.PrintWriter;
 import java.io.StringWriter;
 import java.util.Objects;
-import java.util.function.Consumer;
 import java.util.function.UnaryOperator;
 
 import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -69,10 +69,10 @@ public class SqlPrettyWriterTest {
     private final boolean expr;
     private final String desc;
     private final String formatted;
-    private final UnaryOperator<SqlPrettyWriter> transform;
+    private final UnaryOperator<SqlWriterConfig> transform;
 
     Sql(String sql, boolean expr, String desc, String formatted,
-        UnaryOperator<SqlPrettyWriter> transform) {
+        UnaryOperator<SqlWriterConfig> transform) {
       this.sql = Objects.requireNonNull(sql);
       this.expr = expr;
       this.desc = desc;
@@ -80,13 +80,10 @@ public class SqlPrettyWriterTest {
       this.transform = Objects.requireNonNull(transform);
     }
 
-    Sql withWriter(Consumer<SqlPrettyWriter> consumer) {
-      Objects.requireNonNull(consumer);
-      return new Sql(sql, expr, desc, formatted, w -> {
-        final SqlPrettyWriter w2 = transform.apply(w);
-        consumer.accept(w2);
-        return w2;
-      });
+    Sql withWriter(UnaryOperator<SqlWriterConfig> transform) {
+      Objects.requireNonNull(transform);
+      return new Sql(sql, expr, desc, formatted, w ->
+          transform.apply(this.transform.apply(w)));
     }
 
     Sql expectingDesc(String desc) {
@@ -108,8 +105,10 @@ public class SqlPrettyWriterTest {
     }
 
     Sql check() {
-      final SqlPrettyWriter prettyWriter =
-          transform.apply(new SqlPrettyWriter(AnsiSqlDialect.DEFAULT));
+      final SqlWriterConfig config =
+          transform.apply(SqlPrettyWriter.config()
+              .withDialect(AnsiSqlDialect.DEFAULT));
+      final SqlPrettyWriter prettyWriter = new SqlPrettyWriter(config);
       final SqlNode node;
       if (expr) {
         final SqlCall valuesCall = (SqlCall) parseQuery("VALUES (" + sql + ")");
@@ -169,11 +168,11 @@ public class SqlPrettyWriterTest {
   }
 
   private Sql sql(String sql) {
-    return new Sql(sql, false, "${desc}", "${formatted}", w -> w);
+    return new Sql(sql, false, null, "${formatted}", w -> w);
   }
 
   private Sql expr(String sql) {
-    return sql(sql).withExpr(true).expectingDesc(null);
+    return sql(sql).withExpr(true);
   }
 
   // ~ Tests ----------------------------------------------------------------
@@ -184,44 +183,113 @@ public class SqlPrettyWriterTest {
 
   @Test public void testIndent8() {
     simple()
-        .withWriter(w -> w.setIndentation(8))
+        .expectingDesc("${desc}")
+        .withWriter(w -> w.withIndentation(8))
         .check();
   }
 
   @Test public void testClausesNotOnNewLine() {
     simple()
-        .withWriter(w -> w.setClauseStartsLine(false))
+        .withWriter(w -> w.withClauseStartsLine(false))
         .check();
   }
 
   @Test public void testSelectListItemsOnSeparateLines() {
     simple()
-        .withWriter(w -> w.setSelectListItemsOnSeparateLines(true))
+        .withWriter(w -> w.withSelectListItemsOnSeparateLines(true))
         .check();
   }
 
-  @Test public void testSelectListExtraIndentFlag() {
+  @Test public void testSelectListNoExtraIndentFlag() {
     simple()
-        .withWriter(w -> w.setSelectListItemsOnSeparateLines(true))
-        .withWriter(w -> w.setSelectListExtraIndentFlag(false))
+        .withWriter(w -> w.withSelectListItemsOnSeparateLines(true)
+            .withSelectListExtraIndentFlag(false)
+            .withClauseEndsLine(true))
+        .check();
+  }
+
+  @Test public void testFold() {
+    simple()
+        .withWriter(w -> w.withLineFolding(SqlWriterConfig.LineFolding.FOLD)
+            .withFoldLength(45))
+        .check();
+  }
+
+  @Test public void testChop() {
+    simple()
+        .withWriter(w -> w.withLineFolding(SqlWriterConfig.LineFolding.CHOP)
+            .withFoldLength(45))
+        .check();
+  }
+
+  @Test public void testChopLeadingComma() {
+    simple()
+        .withWriter(w -> w.withLineFolding(SqlWriterConfig.LineFolding.CHOP)
+            .withFoldLength(45)
+            .withLeadingComma(true))
+        .check();
+  }
+
+  @Test public void testLeadingComma() {
+    simple()
+        .withWriter(w -> w.withLeadingComma(true)
+            .withSelectListItemsOnSeparateLines(true)
+            .withSelectListExtraIndentFlag(true))
+        .check();
+  }
+
+  @Test public void testClauseEndsLine() {
+    simple()
+        .withWriter(w -> w.withClauseEndsLine(true)
+            .withLineFolding(SqlWriterConfig.LineFolding.WIDE)
+            .withFoldLength(45))
+        .check();
+  }
+
+  @Test public void testClauseEndsLineTall() {
+    simple()
+        .withWriter(w -> w.withClauseEndsLine(true)
+            .withLineFolding(SqlWriterConfig.LineFolding.TALL)
+            .withFoldLength(45))
+        .check();
+  }
+
+  @Test public void testClauseEndsLineFold() {
+    simple()
+        .withWriter(w -> w.withClauseEndsLine(true)
+            .withLineFolding(SqlWriterConfig.LineFolding.FOLD)
+            .withFoldLength(45))
+        .check();
+  }
+
+  /** Tests formatting a query with Looker's preferences. */
+  @Test public void testLooker() {
+    simple()
+        .withWriter(w -> w.withFoldLength(60)
+            .withLineFolding(SqlWriterConfig.LineFolding.STEP)
+            .withSelectFolding(SqlWriterConfig.LineFolding.TALL)
+            .withFromFolding(SqlWriterConfig.LineFolding.TALL)
+            .withWhereFolding(SqlWriterConfig.LineFolding.TALL)
+            .withHavingFolding(SqlWriterConfig.LineFolding.TALL)
+            .withClauseEndsLine(true))
         .check();
   }
 
   @Test public void testKeywordsLowerCase() {
     simple()
-        .withWriter(w -> w.setKeywordsLowerCase(true))
+        .withWriter(w -> w.withKeywordsLowerCase(true))
         .check();
   }
 
   @Test public void testParenthesizeAllExprs() {
     simple()
-        .withWriter(w -> w.setAlwaysUseParentheses(true))
+        .withWriter(w -> w.withAlwaysUseParentheses(true))
         .check();
   }
 
   @Test public void testOnlyQuoteIdentifiersWhichNeedIt() {
     simple()
-        .withWriter(w -> w.setQuoteAllIdentifiers(false))
+        .withWriter(w -> w.withQuoteAllIdentifiers(false))
         .check();
   }
 
@@ -229,7 +297,27 @@ public class SqlPrettyWriterTest {
     // Note that ( is at the indent, SELECT is on the same line, and ) is
     // below it.
     simple()
-        .withWriter(w -> w.setSubQueryStyle(SqlWriter.SubQueryStyle.BLACK))
+        .withWriter(w -> w.withSubQueryStyle(SqlWriter.SubQueryStyle.BLACK))
+        .check();
+  }
+
+  @Test public void testBlackSubQueryStyleIndent0() {
+    simple()
+        .withWriter(w -> w.withSubQueryStyle(SqlWriter.SubQueryStyle.BLACK)
+            .withIndentation(0))
+        .check();
+  }
+
+  @Test public void testValuesNewline() {
+    sql("select * from (values (1, 2), (3, 4)) as t")
+        .withWriter(w -> w.withValuesListNewline(true))
+        .check();
+  }
+
+  @Test public void testValuesLeadingCommas() {
+    sql("select * from (values (1, 2), (3, 4)) as t")
+        .withWriter(w -> w.withValuesListNewline(true)
+            .withLeadingComma(true))
         .check();
   }
 
@@ -260,7 +348,7 @@ public class SqlPrettyWriterTest {
         + "ELSE 7\n"
         + "END";
     expr(sql)
-        .withWriter(w -> w.setCaseClausesOnNewLines(true))
+        .withWriter(w -> w.withCaseClausesOnNewLines(true))
         .expectingFormatted(formatted)
         .check();
   }
@@ -310,10 +398,6 @@ public class SqlPrettyWriterTest {
   }
 
   @Test public void testUnion() {
-    // todo: SELECT should not be indented from UNION, like this:
-    // UNION
-    //     SELECT *
-    //     FROM `W`
     final String sql = "select * from t "
         + "union select * from ("
         + "  select * from u "
@@ -321,19 +405,50 @@ public class SqlPrettyWriterTest {
         + "union select * from w "
         + "order by a, b";
     sql(sql)
-        .expectingDesc(null)
         .check();
   }
 
   @Test public void testMultiset() {
     sql("values (multiset (select * from t))")
-        .expectingDesc(null)
         .check();
   }
 
+  @Test public void testJoinComma() {
+    final String sql = "select *\n"
+        + "from x, y as y1, z, (select * from a, a2 as a3),\n"
+        + " (select * from b) as b2\n"
+        + "where p = q\n"
+        + "and exists (select 1 from v, w)";
+    sql(sql).check();
+  }
+
   @Test public void testInnerJoin() {
     sql("select * from x inner join y on x.k=y.k")
-        .expectingDesc(null)
+        .check();
+  }
+
+  @Test public void testJoinTall() {
+    sql("select * from x inner join y on x.k=y.k left join z using (a)")
+        .withWriter(c -> c.withLineFolding(SqlWriterConfig.LineFolding.TALL))
+        .check();
+  }
+
+  @Test public void testJoinTallClauseEndsLine() {
+    sql("select * from x inner join y on x.k=y.k left join z using (a)")
+        .withWriter(c -> c.withLineFolding(SqlWriterConfig.LineFolding.TALL)
+            .withClauseEndsLine(true))
+        .check();
+  }
+
+  @Test public void testJoinLateralSubQueryTall() {
+    final String sql = "select *\n"
+        + "from (select a from customers where b < c group by d) as c,\n"
+        + " products,\n"
+        + " lateral (select e from orders where exists (\n"
+        + "    select 1 from promotions)) as t5\n"
+        + "group by f";
+    sql(sql)
+        .withWriter(c -> c.withLineFolding(SqlWriterConfig.LineFolding.TALL))
         .check();
   }
 
@@ -344,9 +459,9 @@ public class SqlPrettyWriterTest {
         + " or ((a or b) is true) and d not in (f,g)"
         + " or x <> z";
     sql(sql)
-        .withWriter(w -> w.setSelectListItemsOnSeparateLines(true))
-        .withWriter(w -> w.setSelectListExtraIndentFlag(false))
-        .withWriter(w -> w.setWhereListItemsOnSeparateLines(true))
+        .withWriter(w -> w.withSelectListItemsOnSeparateLines(true)
+            .withSelectListExtraIndentFlag(false)
+            .withWhereListItemsOnSeparateLines(true))
         .check();
   }
 
@@ -357,9 +472,80 @@ public class SqlPrettyWriterTest {
         + " or ((a or b) is true)) and (d not in (f,g)"
         + " or v <> ((w * x) + y) * z)";
     sql(sql)
-        .withWriter(w -> w.setSelectListItemsOnSeparateLines(true))
-        .withWriter(w -> w.setSelectListExtraIndentFlag(false))
-        .withWriter(w -> w.setWhereListItemsOnSeparateLines(true))
+        .withWriter(w -> w.withSelectListItemsOnSeparateLines(true)
+            .withSelectListExtraIndentFlag(false)
+            .withWhereListItemsOnSeparateLines(true))
+        .check();
+  }
+
+  /** As {@link #testWhereListItemsOnSeparateLinesAnd()}, but
+   * with {@link SqlWriterConfig#clauseEndsLine ClauseEndsLine=true}. */
+  @Test public void testWhereListItemsOnSeparateLinesAndNewline() {
+    final String sql = "select x"
+        + " from y"
+        + " where h is not null and (i < j"
+        + " or ((a or b) is true)) and (d not in (f,g)"
+        + " or v <> ((w * x) + y) * z)";
+    sql(sql)
+        .withWriter(w -> w.withSelectListItemsOnSeparateLines(true)
+            .withSelectListExtraIndentFlag(false)
+            .withWhereListItemsOnSeparateLines(true)
+            .withClauseEndsLine(true))
+        .check();
+  }
+
+  @Test public void testUpdate() {
+    final String sql = "update emp\n"
+        + "set mgr = mgr + 1, deptno = 5\n"
+        + "where deptno = 10 and name = 'Fred'";
+    sql(sql)
         .check();
   }
+
+  @Test public void testUpdateNoLine() {
+    final String sql = "update emp\n"
+        + "set mgr = mgr + 1, deptno = 5\n"
+        + "where deptno = 10 and name = 'Fred'";
+    sql(sql)
+        .withWriter(w -> w.withUpdateSetListNewline(false))
+        .check();
+  }
+
+  @Test public void testUpdateNoLine2() {
+    final String sql = "update emp\n"
+        + "set mgr = mgr + 1, deptno = 5\n"
+        + "where deptno = 10 and name = 'Fred'";
+    sql(sql)
+        .withWriter(w -> w.withUpdateSetListNewline(false)
+            .withClauseStartsLine(false))
+        .check();
+  }
+
+  public static void main(String[] args) throws SqlParseException {
+    final String sql = "select x as a, b as b, c as c, d,"
+        + " 'mixed-Case string',"
+        + " unquotedCamelCaseId,"
+        + " \"quoted id\" "
+        + "from"
+        + " (select *"
+        + " from t"
+        + " where x = y and a > 5"
+        + " group by z, zz"
+        + " window w as (partition by c),"
+        + "  w1 as (partition by c,d order by a, b"
+        + "   range between interval '2:2' hour to minute preceding"
+        + "    and interval '1' day following)) "
+        + "order by gg desc nulls last, hh asc";
+    final SqlNode node = SqlParser.create(sql).parseQuery();
+
+    final SqlWriterConfig config = SqlPrettyWriter.config()
+        .withLineFolding(SqlWriterConfig.LineFolding.STEP)
+        .withSelectFolding(SqlWriterConfig.LineFolding.TALL)
+        .withFromFolding(SqlWriterConfig.LineFolding.TALL)
+        .withWhereFolding(SqlWriterConfig.LineFolding.TALL)
+        .withHavingFolding(SqlWriterConfig.LineFolding.TALL)
+        .withIndentation(4)
+        .withClauseEndsLine(true);
+    System.out.println(new SqlPrettyWriter(config).format(node));
+  }
 }
diff --git a/core/src/test/java/org/apache/calcite/test/JdbcAdapterTest.java b/core/src/test/java/org/apache/calcite/test/JdbcAdapterTest.java
index e4336fc..25f0c0c 100644
--- a/core/src/test/java/org/apache/calcite/test/JdbcAdapterTest.java
+++ b/core/src/test/java/org/apache/calcite/test/JdbcAdapterTest.java
@@ -306,7 +306,7 @@ public class JdbcAdapterTest {
             + "\"t0\".\"DNAME\", \"SALGRADE\".\"GRADE\"\n"
             + "FROM \"SCOTT\".\"SALGRADE\"\n"
             + "INNER JOIN ((SELECT \"EMPNO\", \"ENAME\", \"SAL\", \"DEPTNO\"\n"
-            + "FROM \"SCOTT\".\"EMP\") AS \"t\"\n"
+            + "FROM \"SCOTT\".\"EMP\") AS \"t\" "
             + "INNER JOIN (SELECT \"DEPTNO\", \"DNAME\"\n"
             + "FROM \"SCOTT\".\"DEPT\") AS \"t0\" ON \"t\".\"DEPTNO\" = \"t0\".\"DEPTNO\")"
             + " ON \"SALGRADE\".\"LOSAL\" < \"t\".\"SAL\" AND \"SALGRADE\".\"HISAL\" > \"t\".\"SAL\"");
diff --git a/core/src/test/resources/org/apache/calcite/sql/test/SqlPrettyWriterTest.xml b/core/src/test/resources/org/apache/calcite/sql/test/SqlPrettyWriterTest.xml
index 3b81a8c..84a38b6 100644
--- a/core/src/test/resources/org/apache/calcite/sql/test/SqlPrettyWriterTest.xml
+++ b/core/src/test/resources/org/apache/calcite/sql/test/SqlPrettyWriterTest.xml
@@ -16,8 +16,20 @@
   ~ limitations under the License.
   -->
 <Root>
+  <TestCase name="testBlackSubQueryStyleIndent0">
+    <Resource name="formatted">
+      <![CDATA[SELECT `X` AS `A`, `B` AS `B`, `C` AS `C`, `D`, 'mixed-Case string', `UNQUOTEDCAMELCASEID`, `quoted id`
+FROM
+(SELECT *
+FROM `T`
+WHERE `X` = `Y` AND `A` > 5
+GROUP BY `Z`, `ZZ`
+WINDOW `W` AS (PARTITION BY `C`),
+`W1` AS (PARTITION BY `C`, `D` ORDER BY `A`, `B` RANGE BETWEEN INTERVAL '2:2' HOUR TO MINUTE PRECEDING AND INTERVAL '1' DAY FOLLOWING))
+ORDER BY `GG`]]>
+    </Resource>
+  </TestCase>
   <TestCase name="testDefault">
-    <Resource name="desc"/>
     <Resource name="formatted">
       <![CDATA[SELECT `X` AS `A`, `B` AS `B`, `C` AS `C`, `D`, 'mixed-Case string', `UNQUOTEDCAMELCASEID`, `quoted id`
 FROM (SELECT *
@@ -44,6 +56,105 @@ ELSE 7
 END]]>
     </Resource>
   </TestCase>
+  <TestCase name="testChop">
+    <Resource name="formatted">
+      <![CDATA[SELECT `X` AS `A`,
+    `B` AS `B`,
+    `C` AS `C`,
+    `D`,
+    'mixed-Case string',
+    `UNQUOTEDCAMELCASEID`,
+    `quoted id`
+FROM (SELECT *
+        FROM `T`
+        WHERE `X` = `Y` AND `A` > 5
+        GROUP BY `Z`, `ZZ`
+        WINDOW `W` AS (PARTITION BY `C`),
+            `W1` AS (PARTITION BY `C`, `D` ORDER BY `A`, `B` RANGE BETWEEN INTERVAL '2:2' HOUR TO MINUTE PRECEDING AND INTERVAL '1' DAY FOLLOWING))
+ORDER BY `GG`]]>
+    </Resource>
+  </TestCase>
+  <TestCase name="testChopLeadingComma">
+    <Resource name="formatted">
+      <![CDATA[SELECT `X` AS `A`
+  , `B` AS `B`
+  , `C` AS `C`
+  , `D`
+  , 'mixed-Case string'
+  , `UNQUOTEDCAMELCASEID`
+  , `quoted id`
+FROM (SELECT *
+        FROM `T`
+        WHERE `X` = `Y` AND `A` > 5
+        GROUP BY `Z`, `ZZ`
+        WINDOW `W` AS (PARTITION BY `C`)
+          , `W1` AS (PARTITION BY `C`, `D` ORDER BY `A`, `B` RANGE BETWEEN INTERVAL '2:2' HOUR TO MINUTE PRECEDING AND INTERVAL '1' DAY FOLLOWING))
+ORDER BY `GG`]]>
+    </Resource>
+  </TestCase>
+  <TestCase name="testClauseEndsLine">
+    <Resource name="formatted">
+      <![CDATA[SELECT `X` AS `A`, `B` AS `B`, `C` AS `C`, `D`, 'mixed-Case string', `UNQUOTEDCAMELCASEID`, `quoted id`
+FROM
+    (SELECT *
+        FROM
+            `T`
+        WHERE `X` = `Y` AND `A` > 5
+        GROUP BY `Z`, `ZZ`
+        WINDOW `W` AS (PARTITION BY `C`), `W1` AS (PARTITION BY `C`, `D` ORDER BY `A`, `B` RANGE BETWEEN INTERVAL '2:2' HOUR TO MINUTE PRECEDING AND INTERVAL '1' DAY FOLLOWING))
+ORDER BY `GG`]]>
+    </Resource>
+  </TestCase>
+  <TestCase name="testClauseEndsLineFold">
+    <Resource name="formatted">
+      <![CDATA[SELECT
+    `X` AS `A`, `B` AS `B`, `C` AS `C`, `D`,
+    'mixed-Case string',
+    `UNQUOTEDCAMELCASEID`, `quoted id`
+FROM
+    (SELECT *
+        FROM
+            `T`
+        WHERE `X` = `Y` AND `A` > 5
+        GROUP BY `Z`, `ZZ`
+        WINDOW
+            `W` AS (PARTITION BY `C`),
+            `W1` AS (PARTITION BY `C`, `D` ORDER BY `A`, `B` RANGE BETWEEN INTERVAL '2:2' HOUR TO MINUTE PRECEDING AND INTERVAL '1' DAY FOLLOWING))
+ORDER BY `GG`]]>
+    </Resource>
+  </TestCase>
+  <TestCase name="testClauseEndsLineTall">
+    <Resource name="formatted">
+      <![CDATA[SELECT
+    `X` AS `A`,
+    `B` AS `B`,
+    `C` AS `C`,
+    `D`,
+    'mixed-Case string',
+    `UNQUOTEDCAMELCASEID`,
+    `quoted id`
+FROM
+    (SELECT
+            *
+        FROM
+            `T`
+        WHERE
+            `X` = `Y`
+            AND `A` > 5
+        GROUP BY
+            `Z`,
+            `ZZ`
+        WINDOW
+            `W` AS (
+                PARTITION BY `C`),
+            `W1` AS (
+                PARTITION BY `C`, `D`
+                ORDER BY `A`, `B`
+                RANGE BETWEEN INTERVAL '2:2' HOUR TO MINUTE PRECEDING AND INTERVAL '1' DAY FOLLOWING))
+ORDER BY
+    `GG`]]>
+    </Resource>
+  </TestCase>
   <TestCase name="testIndent8">
     <Resource name="desc">
       <![CDATA[indentation=8]]>
@@ -60,39 +171,45 @@ ORDER BY `GG`]]>
     </Resource>
   </TestCase>
   <TestCase name="testClausesNotOnNewLine">
-    <Resource name="desc"/>
     <Resource name="formatted">
-      <![CDATA[SELECT `X` AS `A`, `B` AS `B`, `C` AS `C`, `D`, 'mixed-Case string', `UNQUOTEDCAMELCASEID`, `quoted id` FROM (SELECT *FROM `T` WHERE `X` = `Y` AND `A` > 5 GROUP BY `Z`, `ZZ` WINDOW `W` AS (PARTITION BY `C`),
+      <![CDATA[SELECT `X` AS `A`, `B` AS `B`, `C` AS `C`, `D`, 'mixed-Case string', `UNQUOTEDCAMELCASEID`, `quoted id` FROM (SELECT *FROM `T` WHERE `X` = `Y` AND `A` > 5 GROUP BY `Z`, `ZZ` WINDOW `W` AS (PARTITION BY `C`), `W1` AS (PARTITION BY `C`, `D` ORDER BY `A`, `B` RANGE BETWEEN INTERVAL '2:2' HOUR TO MINUTE PRECEDING AND INTERVAL '1' DAY FOLLOWING))
+ORDER BY `GG`]]>
+    </Resource>
+  </TestCase>
+  <TestCase name="testFold">
+    <Resource name="formatted">
+      <![CDATA[SELECT `X` AS `A`, `B` AS `B`, `C` AS `C`,
+    `D`, 'mixed-Case string',
+    `UNQUOTEDCAMELCASEID`, `quoted id`
+FROM (SELECT *
+        FROM `T`
+        WHERE `X` = `Y` AND `A` > 5
+        GROUP BY `Z`, `ZZ`
+        WINDOW `W` AS (PARTITION BY `C`),
             `W1` AS (PARTITION BY `C`, `D` ORDER BY `A`, `B` RANGE BETWEEN INTERVAL '2:2' HOUR TO MINUTE PRECEDING AND INTERVAL '1' DAY FOLLOWING))
 ORDER BY `GG`]]>
     </Resource>
   </TestCase>
   <TestCase name="testSelectListItemsOnSeparateLines">
-    <Resource name="desc"/>
     <Resource name="formatted">
-      <![CDATA[SELECT
-    `X` AS `A`,
-        `B` AS `B`,
-        `C` AS `C`,
+      <![CDATA[SELECT `X` AS `A`,
+    `B` AS `B`,
+    `C` AS `C`,
     `D`,
     'mixed-Case string',
     `UNQUOTEDCAMELCASEID`,
     `quoted id`
-FROM (SELECT
-            *
+FROM (SELECT *
         FROM `T`
         WHERE `X` = `Y` AND `A` > 5
-        GROUP BY
-            `Z`,
+        GROUP BY `Z`,
             `ZZ`
         WINDOW `W` AS (PARTITION BY `C`),
             `W1` AS (PARTITION BY `C`, `D` ORDER BY `A`, `B` RANGE BETWEEN INTERVAL '2:2' HOUR TO MINUTE PRECEDING AND INTERVAL '1' DAY FOLLOWING))
-ORDER BY
-`GG`]]>
+ORDER BY `GG`]]>
     </Resource>
   </TestCase>
-  <TestCase name="testSelectListExtraIndentFlag">
-    <Resource name="desc"/>
+  <TestCase name="testSelectListNoExtraIndentFlag">
     <Resource name="formatted">
       <![CDATA[SELECT
     `X` AS `A`,
@@ -102,21 +219,23 @@ ORDER BY
     'mixed-Case string',
     `UNQUOTEDCAMELCASEID`,
     `quoted id`
-FROM (SELECT
+FROM
+    (SELECT
             *
-        FROM `T`
+        FROM
+            `T`
         WHERE `X` = `Y` AND `A` > 5
         GROUP BY
             `Z`,
             `ZZ`
-        WINDOW `W` AS (PARTITION BY `C`),
+        WINDOW
+            `W` AS (PARTITION BY `C`),
             `W1` AS (PARTITION BY `C`, `D` ORDER BY `A`, `B` RANGE BETWEEN INTERVAL '2:2' HOUR TO MINUTE PRECEDING AND INTERVAL '1' DAY FOLLOWING))
 ORDER BY
-`GG`]]>
+    `GG`]]>
     </Resource>
   </TestCase>
   <TestCase name="testKeywordsLowerCase">
-    <Resource name="desc"/>
     <Resource name="formatted">
       <![CDATA[select `X` as `A`, `B` as `B`, `C` as `C`, `D`, 'mixed-Case string', `UNQUOTEDCAMELCASEID`, `quoted id`
 from (select *
@@ -129,7 +248,6 @@ order by `GG`]]>
     </Resource>
   </TestCase>
   <TestCase name="testParenthesizeAllExprs">
-    <Resource name="desc"/>
     <Resource name="formatted">
       <![CDATA[SELECT `X` AS `A`, `B` AS `B`, `C` AS `C`, `D`, 'mixed-Case string', `UNQUOTEDCAMELCASEID`, `quoted id`
 FROM (SELECT *
@@ -142,7 +260,6 @@ ORDER BY `GG`]]>
     </Resource>
   </TestCase>
   <TestCase name="testOnlyQuoteIdentifiersWhichNeedIt">
-    <Resource name="desc"/>
     <Resource name="formatted">
       <![CDATA[SELECT X AS A, B AS B, C AS C, D, 'mixed-Case string', UNQUOTEDCAMELCASEID, `quoted id`
 FROM (SELECT *
@@ -155,10 +272,10 @@ ORDER BY GG]]>
     </Resource>
   </TestCase>
   <TestCase name="testBlackSubQueryStyle">
-    <Resource name="desc"/>
     <Resource name="formatted">
       <![CDATA[SELECT `X` AS `A`, `B` AS `B`, `C` AS `C`, `D`, 'mixed-Case string', `UNQUOTEDCAMELCASEID`, `quoted id`
-FROM (    SELECT *
+FROM
+(   SELECT *
         FROM `T`
         WHERE `X` = `Y` AND `A` > 5
         GROUP BY `Z`, `ZZ`
@@ -182,6 +299,74 @@ ORDER BY `GG`]]>
       <![CDATA[CAST(`X` + `Y` AS DECIMAL(5, 10))]]>
     </Resource>
   </TestCase>
+  <TestCase name="testJoinComma">
+    <Resource name="formatted">
+      <![CDATA[SELECT *
+FROM `X`,
+    `Y` AS `Y1`,
+    `Z`,
+        (SELECT *
+        FROM `A`,
+            `A2` AS `A3`),
+        (SELECT *
+        FROM `B`) AS `B2`
+WHERE `P` = `Q` AND EXISTS (SELECT 1
+        FROM `V`,
+            `W`)]]>
+    </Resource>
+  </TestCase>
+  <TestCase name="testJoinLateralSubQueryTall">
+    <Resource name="formatted">
+      <![CDATA[SELECT *
+FROM (SELECT `A`
+        FROM `CUSTOMERS`
+        WHERE `B` < `C`
+        GROUP BY `D`) AS `C`,
+    `PRODUCTS`,
+    LATERAL (SELECT `E`
+        FROM `ORDERS`
+        WHERE EXISTS (SELECT 1
+                FROM `PROMOTIONS`)) AS `T5`
+GROUP BY `F`]]>
+    </Resource>
+  </TestCase>
+  <TestCase name="testJoinTall">
+    <Resource name="formatted">
+      <![CDATA[SELECT *
+FROM `X`
+    INNER JOIN `Y` ON `X`.`K` = `Y`.`K`
+    LEFT JOIN `Z` USING (`A`)]]>
+    </Resource>
+  </TestCase>
+  <TestCase name="testJoinTallClauseEndsLine">
+    <Resource name="formatted">
+      <![CDATA[SELECT
+    *
+FROM
+    `X`
+    INNER JOIN `Y` ON `X`.`K` = `Y`.`K`
+    LEFT JOIN `Z` USING (`A`)]]>
+    </Resource>
+  </TestCase>
+  <TestCase name="testLeadingComma">
+    <Resource name="formatted">
+      <![CDATA[SELECT `X` AS `A`
+  , `B` AS `B`
+  , `C` AS `C`
+  , `D`
+  , 'mixed-Case string'
+  , `UNQUOTEDCAMELCASEID`
+  , `quoted id`
+FROM (SELECT *
+        FROM `T`
+        WHERE `X` = `Y` AND `A` > 5
+        GROUP BY `Z`
+          , `ZZ`
+        WINDOW `W` AS (PARTITION BY `C`)
+          , `W1` AS (PARTITION BY `C`, `D` ORDER BY `A`, `B` RANGE BETWEEN INTERVAL '2:2' HOUR TO MINUTE PRECEDING AND INTERVAL '1' DAY FOLLOWING))
+ORDER BY `GG`]]>
+    </Resource>
+  </TestCase>
   <TestCase name="testLiteralChain">
     <Resource name="formatted">
       <![CDATA['x'
@@ -189,25 +374,52 @@ ORDER BY `GG`]]>
 'z']]>
     </Resource>
   </TestCase>
+  <TestCase name="testLooker">
+    <Resource name="formatted">
+      <![CDATA[SELECT
+    `X` AS `A`,
+    `B` AS `B`,
+    `C` AS `C`,
+    `D`,
+    'mixed-Case string',
+    `UNQUOTEDCAMELCASEID`,
+    `quoted id`
+FROM
+    (SELECT
+            *
+        FROM
+            `T`
+        WHERE
+            `X` = `Y`
+            AND `A` > 5
+        GROUP BY
+            `Z`, `ZZ`
+        WINDOW
+            `W` AS (PARTITION BY `C`),
+            `W1` AS (PARTITION BY `C`, `D` ORDER BY `A`, `B` RANGE BETWEEN INTERVAL '2:2' HOUR TO MINUTE PRECEDING AND INTERVAL '1' DAY FOLLOWING))
+ORDER BY
+    `GG`]]>
+    </Resource>
+  </TestCase>
   <TestCase name="testOverlaps">
     <Resource name="formatted">
-      <![CDATA[(`X`, `XX`) OVERLAPS (`Y`, `YY`) OR `X` IS NOT NULL]]>
+      <![CDATA[PERIOD (`X`, `XX`) OVERLAPS PERIOD (`Y`, `YY`) OR `X` IS NOT NULL]]>
     </Resource>
   </TestCase>
   <TestCase name="testUnion">
     <Resource name="formatted">
       <![CDATA[SELECT *
-        FROM `T`
-    UNION
-        SELECT *
-        FROM (SELECT *
-                        FROM `U`
-                    UNION
-                        SELECT *
-                        FROM `V`)
+FROM `T`
+UNION
+SELECT *
+FROM (SELECT *
+            FROM `U`
+            UNION
+            SELECT *
+            FROM `V`)
 UNION
-    SELECT *
-    FROM `W`
+SELECT *
+FROM `W`
 ORDER BY `A`, `B`]]>
     </Resource>
   </TestCase>
@@ -224,11 +436,42 @@ FROM `X`
     INNER JOIN `Y` ON `X`.`K` = `Y`.`K`]]>
     </Resource>
   </TestCase>
+  <TestCase name="testUpdate">
+    <Resource name="formatted">
+      <![CDATA[UPDATE `EMP` SET
+    `MGR` = `MGR` + 1,
+    `DEPTNO` = 5
+WHERE `DEPTNO` = 10 AND `NAME` = 'Fred']]>
+    </Resource>
+  </TestCase>
+  <TestCase name="testUpdateNoLine">
+    <Resource name="formatted">
+      <![CDATA[UPDATE `EMP` SET `MGR` = `MGR` + 1, `DEPTNO` = 5
+WHERE `DEPTNO` = 10 AND `NAME` = 'Fred']]>
+    </Resource>
+  </TestCase>
+  <TestCase name="testUpdateNoLine2">
+    <Resource name="formatted">
+      <![CDATA[UPDATE `EMP` SET `MGR` = `MGR` + 1, `DEPTNO` = 5 WHERE `DEPTNO` = 10 AND `NAME` = 'Fred']]>
+    </Resource>
+  </TestCase>
+  <TestCase name="testValuesLeadingCommas">
+    <Resource name="formatted">
+      <![CDATA[SELECT *
+FROM (VALUES ROW(1, 2)
+          , ROW(3, 4)) AS `T`]]>
+    </Resource>
+  </TestCase>
+  <TestCase name="testValuesNewline">
+    <Resource name="formatted">
+      <![CDATA[SELECT *
+FROM (VALUES ROW(1, 2),
+            ROW(3, 4)) AS `T`]]>
+    </Resource>
+  </TestCase>
   <TestCase name="testWhereListItemsOnSeparateLinesOr">
-    <Resource name="desc"/>
     <Resource name="formatted">
-      <![CDATA[SELECT
-    `X`
+      <![CDATA[SELECT `X`
 FROM `Y`
 WHERE `H` IS NOT NULL AND `I` < `J`
     OR (`A` OR `B`) IS TRUE AND `D` NOT IN (`F`, `G`)
@@ -236,14 +479,24 @@ WHERE `H` IS NOT NULL AND `I` < `J`
     </Resource>
   </TestCase>
   <TestCase name="testWhereListItemsOnSeparateLinesAnd">
-    <Resource name="desc"/>
     <Resource name="formatted">
-      <![CDATA[SELECT
-    `X`
+      <![CDATA[SELECT `X`
 FROM `Y`
 WHERE `H` IS NOT NULL
     AND (`I` < `J` OR (`A` OR `B`) IS TRUE)
     AND (`D` NOT IN (`F`, `G`) OR `V` <> (`W` * `X` + `Y`) * `Z`)]]>
     </Resource>
   </TestCase>
+  <TestCase name="testWhereListItemsOnSeparateLinesAndNewline">
+    <Resource name="formatted">
+      <![CDATA[SELECT
+    `X`
+FROM
+    `Y`
+WHERE
+    `H` IS NOT NULL
+    AND (`I` < `J` OR (`A` OR `B`) IS TRUE)
+    AND (`D` NOT IN (`F`, `G`) OR `V` <> (`W` * `X` + `Y`) * `Z`)]]>
+    </Resource>
+  </TestCase>
 </Root>
diff --git a/piglet/src/main/java/org/apache/calcite/piglet/PigConverter.java b/piglet/src/main/java/org/apache/calcite/piglet/PigConverter.java
index a7a8e63..2db0bb7 100644
--- a/piglet/src/main/java/org/apache/calcite/piglet/PigConverter.java
+++ b/piglet/src/main/java/org/apache/calcite/piglet/PigConverter.java
@@ -35,6 +35,7 @@ import org.apache.calcite.rel.rules.ProjectWindowTransposeRule;
 import org.apache.calcite.sql.SqlDialect;
 import org.apache.calcite.sql.SqlNode;
 import org.apache.calcite.sql.SqlWriter;
+import org.apache.calcite.sql.SqlWriterConfig;
 import org.apache.calcite.sql.pretty.SqlPrettyWriter;
 import org.apache.calcite.tools.FrameworkConfig;
 import org.apache.calcite.tools.Program;
@@ -221,11 +222,13 @@ public class PigConverter extends PigServer {
    */
   public List<String> pigToSql(String pigQuery, SqlDialect sqlDialect)
       throws IOException {
-    final SqlPrettyWriter writer = new SqlPrettyWriter(sqlDialect);
-    writer.setQuoteAllIdentifiers(false);
-    writer.setAlwaysUseParentheses(false);
-    writer.setSelectListItemsOnSeparateLines(false);
-    writer.setIndentation(2);
+    final SqlWriterConfig config = SqlPrettyWriter.config()
+        .withQuoteAllIdentifiers(false)
+        .withAlwaysUseParentheses(false)
+        .withSelectListItemsOnSeparateLines(false)
+        .withIndentation(2)
+        .withDialect(sqlDialect);
+    final SqlPrettyWriter writer = new SqlPrettyWriter(config);
     return pigToSql(pigQuery, writer);
   }
 
diff --git a/piglet/src/test/java/org/apache/calcite/test/PigRelOpTest.java b/piglet/src/test/java/org/apache/calcite/test/PigRelOpTest.java
index 4098989..feb6020 100644
--- a/piglet/src/test/java/org/apache/calcite/test/PigRelOpTest.java
+++ b/piglet/src/test/java/org/apache/calcite/test/PigRelOpTest.java
@@ -253,12 +253,12 @@ public class PigRelOpTest extends PigRelTestBase {
 
     final String sql = ""
         + "SELECT *\n"
-        + "  FROM scott.EMP\n"
-        + "  WHERE DEPTNO = 10\n"
+        + "FROM scott.EMP\n"
+        + "WHERE DEPTNO = 10\n"
         + "UNION ALL\n"
-        + "  SELECT *\n"
-        + "  FROM scott.EMP\n"
-        + "  WHERE DEPTNO = 20";
+        + "SELECT *\n"
+        + "FROM scott.EMP\n"
+        + "WHERE DEPTNO = 20";
     pig(script)
         .assertRel("B1", false,
             hasTree("LogicalFilter(condition=[=($7, 10)])\n"
@@ -377,11 +377,11 @@ public class PigRelOpTest extends PigRelTestBase {
         + " $cor1.SAL, $cor1.COMM, $cor1.DEPTNO0 AS DEPTNO\n"
         + "FROM (SELECT DEPTNO, JOB, COLLECT(ROW(EMPNO, ENAME, JOB, MGR, "
         + "HIREDATE, SAL, COMM, DEPTNO)) AS $f2\n"
-        + "      FROM scott.EMP\n"
-        + "      WHERE JOB <> 'CLERK'\n"
-        + "      GROUP BY DEPTNO, JOB) AS $cor1,\n"
-        + "    LATERAL UNNEST (SELECT $cor1.$f2 AS $f0\n"
-        + "        FROM (VALUES  (0)) AS t (ZERO)) AS t3 (EMPNO, ENAME, JOB,"
+        + "    FROM scott.EMP\n"
+        + "    WHERE JOB <> 'CLERK'\n"
+        + "    GROUP BY DEPTNO, JOB) AS $cor1,\n"
+        + "  LATERAL UNNEST (SELECT $cor1.$f2 AS $f0\n"
+        + "    FROM (VALUES  (0)) AS t (ZERO)) AS t3 (EMPNO, ENAME, JOB,"
         + " MGR, HIREDATE, SAL, COMM, DEPTNO) AS t30\n"
         + "ORDER BY $cor1.DEPTNO, $cor1.JOB";
     pig(script).assertRel(hasTree(plan))
@@ -464,20 +464,20 @@ public class PigRelOpTest extends PigRelTestBase {
         + "FROM (SELECT $cor4.DEPTNO AS group, "
         + "COUNT(PIG_BAG($cor4.X)) AS cnt, $cor4.X, "
         + "BigDecimalMax(PIG_BAG(MULTISET_PROJECTION($cor4.X, 3))) AS $f3\n"
-        + "      FROM (SELECT DEPTNO, COLLECT(ROW(EMPNO, ENAME, JOB, MGR, "
+        + "    FROM (SELECT DEPTNO, COLLECT(ROW(EMPNO, ENAME, JOB, MGR, "
         + "HIREDATE, SAL, COMM, DEPTNO)) AS A\n"
-        + "            FROM scott.EMP\n"
-        + "            GROUP BY DEPTNO) AS $cor4,\n"
-        + "          LATERAL (SELECT COLLECT(ROW(ENAME, JOB, DEPTNO, SAL)) AS X\n"
-        + "            FROM (SELECT ENAME, JOB, DEPTNO, SAL\n"
-        + "                  FROM UNNEST (SELECT $cor4.A AS $f0\n"
-        + "                        FROM (VALUES  (0)) AS t (ZERO)) "
+        + "        FROM scott.EMP\n"
+        + "        GROUP BY DEPTNO) AS $cor4,\n"
+        + "      LATERAL (SELECT COLLECT(ROW(ENAME, JOB, DEPTNO, SAL)) AS X\n"
+        + "        FROM (SELECT ENAME, JOB, DEPTNO, SAL\n"
+        + "            FROM UNNEST (SELECT $cor4.A AS $f0\n"
+        + "                FROM (VALUES  (0)) AS t (ZERO)) "
         + "AS t2 (EMPNO, ENAME, JOB, MGR, HIREDATE, SAL, COMM, DEPTNO)\n"
-        + "                  WHERE JOB <> 'CLERK'\n"
-        + "                  ORDER BY SAL) AS t5\n"
-        + "            GROUP BY 'all') AS t8) AS $cor5,\n"
-        + "    LATERAL UNNEST (SELECT $cor5.X AS $f0\n"
-        + "        FROM (VALUES  (0)) AS t (ZERO)) "
+        + "            WHERE JOB <> 'CLERK'\n"
+        + "            ORDER BY SAL) AS t5\n"
+        + "        GROUP BY 'all') AS t8) AS $cor5,\n"
+        + "  LATERAL UNNEST (SELECT $cor5.X AS $f0\n"
+        + "    FROM (VALUES  (0)) AS t (ZERO)) "
         + "AS t11 (ENAME, JOB, DEPTNO, SAL) AS t110\n"
         + "ORDER BY $cor5.group";
     pig(script).assertRel(hasTree(plan))
@@ -510,12 +510,12 @@ public class PigRelOpTest extends PigRelTestBase {
         + "(7902,FORD,ANALYST,7566,1981-12-03,3000.00,null,20)\n";
     final String sql = ""
         + "SELECT *\n"
-        + "  FROM scott.EMP\n"
-        + "  WHERE DEPTNO = 10\n"
+        + "FROM scott.EMP\n"
+        + "WHERE DEPTNO = 10\n"
         + "UNION ALL\n"
-        + "  SELECT *\n"
-        + "  FROM scott.EMP\n"
-        + "  WHERE DEPTNO = 20";
+        + "SELECT *\n"
+        + "FROM scott.EMP\n"
+        + "WHERE DEPTNO = 20";
     pig(script).assertRel(hasTree(plan))
         .assertResult(is(result))
         .assertSql(is(sql));
@@ -548,11 +548,11 @@ public class PigRelOpTest extends PigRelTestBase {
         + "(40,OPERATIONS,null)\n";
     final String sql = ""
         + "SELECT *\n"
-        + "  FROM scott.DEPT\n"
+        + "FROM scott.DEPT\n"
         + "UNION ALL\n"
-        + "  SELECT DEPTNO, DNAME, "
+        + "SELECT DEPTNO, DNAME, "
         + "CAST(NULL AS VARCHAR CHARACTER SET ISO-8859-1) AS LOC\n"
-        + "  FROM scott.DEPT";
+        + "FROM scott.DEPT";
     pig(script).assertRel(hasTree(plan))
         .assertOptimizedRel(hasTree(optimizedPlan))
         .assertResult(is(result))
@@ -592,16 +592,16 @@ public class PigRelOpTest extends PigRelTestBase {
         + "SELECT EMPNO, ENAME, JOB, MGR, HIREDATE, SAL, COMM, DEPTNO, "
         + "CAST(NULL AS VARCHAR CHARACTER SET ISO-8859-1) AS DNAME, "
         + "CAST(NULL AS VARCHAR CHARACTER SET ISO-8859-1) AS LOC\n"
-        + "  FROM scott.EMP\n"
-        + "  WHERE DEPTNO = 10\n"
+        + "FROM scott.EMP\n"
+        + "WHERE DEPTNO = 10\n"
         + "UNION ALL\n"
-        + "  SELECT CAST(NULL AS INTEGER) AS EMPNO, "
+        + "SELECT CAST(NULL AS INTEGER) AS EMPNO, "
         + "CAST(NULL AS VARCHAR CHARACTER SET ISO-8859-1) AS ENAME, "
         + "CAST(NULL AS VARCHAR CHARACTER SET ISO-8859-1) AS JOB, "
         + "CAST(NULL AS INTEGER) AS MGR, "
         + "CAST(NULL AS DATE) AS HIREDATE, CAST(NULL AS DECIMAL(19, 0)) AS SAL, "
         + "CAST(NULL AS DECIMAL(19, 0)) AS COMM, DEPTNO, DNAME, LOC\n"
-        + "  FROM scott.DEPT";
+        + "FROM scott.DEPT";
     pig(script).assertRel(hasTree(plan))
         .assertResult(is(result))
         .assertSql(is(sql));
@@ -720,8 +720,8 @@ public class PigRelOpTest extends PigRelTestBase {
         + "FROM scott.EMP\n"
         + "  INNER JOIN scott.DEPT ON EMP.DEPTNO = DEPT.DEPTNO\n"
         + "  INNER JOIN (SELECT *\n"
-        + "      FROM scott.DEPT\n"
-        + "      WHERE LOC = 'CHICAGO') AS t ON EMP.DEPTNO = t.DEPTNO";
+        + "    FROM scott.DEPT\n"
+        + "    WHERE LOC = 'CHICAGO') AS t ON EMP.DEPTNO = t.DEPTNO";
     final String result = ""
         + "(7499,ALLEN,SALESMAN,7698,1981-02-20,1600.00,300.00,30,30,"
         + "SALES,CHICAGO,30,SALES,"
@@ -769,8 +769,8 @@ public class PigRelOpTest extends PigRelTestBase {
         + "  INNER JOIN scott.DEPT ON EMP.DEPTNO = DEPT.DEPTNO "
         + "AND EMP.ENAME = DEPT.DNAME\n"
         + "  INNER JOIN (SELECT *\n"
-        + "      FROM scott.DEPT\n"
-        + "      WHERE LOC = 'CHICAGO') AS t ON EMP.DEPTNO = t.DEPTNO "
+        + "    FROM scott.DEPT\n"
+        + "    WHERE LOC = 'CHICAGO') AS t ON EMP.DEPTNO = t.DEPTNO "
         + "AND DEPT.DNAME = t.DNAME";
     pig(script2).assertRel(hasTree(plan2))
         .assertSql(is(sql2));
@@ -793,10 +793,10 @@ public class PigRelOpTest extends PigRelTestBase {
     final String sql = ""
         + "SELECT *\n"
         + "FROM (SELECT DEPTNO\n"
-        + "      FROM scott.DEPT) AS t,\n"
-        + "      (SELECT DEPTNO\n"
-        + "      FROM scott.DEPT\n"
-        + "      WHERE DEPTNO <= 20) AS t1";
+        + "    FROM scott.DEPT) AS t,\n"
+        + "    (SELECT DEPTNO\n"
+        + "    FROM scott.DEPT\n"
+        + "    WHERE DEPTNO <= 20) AS t1";
     final String result = ""
         + "(10,10)\n"
         + "(10,20)\n"
@@ -850,13 +850,13 @@ public class PigRelOpTest extends PigRelTestBase {
     final String sql2 = ""
         + "SELECT *\n"
         + "FROM (SELECT DEPTNO\n"
-        + "      FROM scott.DEPT) AS t,\n"
-        + "      (SELECT DEPTNO\n"
-        + "      FROM scott.DEPT\n"
-        + "      WHERE DEPTNO <= 20) AS t1,\n"
-        + "      (SELECT DEPTNO\n"
-        + "      FROM scott.DEPT\n"
-        + "      WHERE DEPTNO > 20) AS t3";
+        + "    FROM scott.DEPT) AS t,\n"
+        + "    (SELECT DEPTNO\n"
+        + "    FROM scott.DEPT\n"
+        + "    WHERE DEPTNO <= 20) AS t1,\n"
+        + "    (SELECT DEPTNO\n"
+        + "    FROM scott.DEPT\n"
+        + "    WHERE DEPTNO > 20) AS t3";
     pig(script2).assertRel(hasTree(plan2))
         .assertResult(is(result2))
         .assertSql(is(sql2));
@@ -1591,19 +1591,21 @@ public class PigRelOpTest extends PigRelTestBase {
         + "AS DEPTNO, t4.A, t4.B, t7.C\n"
         + "FROM (SELECT CASE WHEN t0.$f0 IS NOT NULL THEN t0.$f0 ELSE t3.DEPTNO END "
         + "AS DEPTNO, t0.A, t3.B\n"
-        + "      FROM (SELECT DEPTNO + 10 AS $f0, COLLECT(ROW(DEPTNO, DNAME, LOC)) AS A\n"
-        + "            FROM scott.DEPT\n"
-        + "            GROUP BY DEPTNO + 10) AS t0\n"
-        + "        FULL JOIN (SELECT CAST(DEPTNO AS INTEGER) AS DEPTNO, COLLECT(ROW"
-        + "(DEPTNO, DNAME, LOC)) AS B\n"
-        + "            FROM scott.DEPT\n"
-        + "            WHERE DEPTNO <= 30\n"
-        + "            GROUP BY CAST(DEPTNO AS INTEGER)) AS t3 ON t0.$f0 = t3.DEPTNO) AS t4\n"
+        + "    FROM (SELECT DEPTNO + 10 AS $f0, "
+        + "COLLECT(ROW(DEPTNO, DNAME, LOC)) AS A\n"
+        + "        FROM scott.DEPT\n"
+        + "        GROUP BY DEPTNO + 10) AS t0\n"
+        + "      FULL JOIN (SELECT CAST(DEPTNO AS INTEGER) AS DEPTNO, "
+        + "COLLECT(ROW(DEPTNO, DNAME, LOC)) AS B\n"
+        + "        FROM scott.DEPT\n"
+        + "        WHERE DEPTNO <= 30\n"
+        + "        GROUP BY CAST(DEPTNO AS INTEGER)) AS t3 "
+        + "ON t0.$f0 = t3.DEPTNO) AS t4\n"
         + "  FULL JOIN (SELECT CAST(DEPTNO AS INTEGER) AS DEPTNO, COLLECT(ROW(DEPTNO, DNAME, "
         + "LOC)) AS C\n"
-        + "      FROM scott.DEPT\n"
-        + "      WHERE DEPTNO >= 20\n"
-        + "      GROUP BY CAST(DEPTNO AS INTEGER)) AS t7 ON t4.DEPTNO = t7.DEPTNO\n"
+        + "    FROM scott.DEPT\n"
+        + "    WHERE DEPTNO >= 20\n"
+        + "    GROUP BY CAST(DEPTNO AS INTEGER)) AS t7 ON t4.DEPTNO = t7.DEPTNO\n"
         + "ORDER BY CASE WHEN t4.DEPTNO IS NOT NULL THEN t4.DEPTNO ELSE t7.DEPTNO END";
     pig(script).assertRel(hasTree(plan))
         .assertResult(is(result))
diff --git a/server/src/main/java/org/apache/calcite/sql/ddl/SqlDdlNodes.java b/server/src/main/java/org/apache/calcite/sql/ddl/SqlDdlNodes.java
index 40fe4cc..d5ac495 100644
--- a/server/src/main/java/org/apache/calcite/sql/ddl/SqlDdlNodes.java
+++ b/server/src/main/java/org/apache/calcite/sql/ddl/SqlDdlNodes.java
@@ -30,7 +30,7 @@ import org.apache.calcite.sql.SqlNode;
 import org.apache.calcite.sql.SqlNodeList;
 import org.apache.calcite.sql.SqlOperator;
 import org.apache.calcite.sql.SqlSelect;
-import org.apache.calcite.sql.dialect.CalciteSqlDialect;
+import org.apache.calcite.sql.SqlWriterConfig;
 import org.apache.calcite.sql.fun.SqlStdOperatorTable;
 import org.apache.calcite.sql.parser.SqlParseException;
 import org.apache.calcite.sql.parser.SqlParserPos;
@@ -46,8 +46,6 @@ import org.apache.calcite.util.Util;
 
 import com.google.common.collect.ImmutableList;
 
-import java.io.PrintWriter;
-import java.io.StringWriter;
 import java.sql.PreparedStatement;
 import java.sql.SQLException;
 import java.util.List;
@@ -214,8 +212,7 @@ public class SqlDdlNodes {
       return query;
     }
     final SqlParserPos p = query.getParserPosition();
-    final SqlNodeList selectList =
-        new SqlNodeList(ImmutableList.<SqlNode>of(SqlIdentifier.star(p)), p);
+    final SqlNodeList selectList = SqlNodeList.SINGLETON_STAR;
     final SqlCall from =
         SqlStdOperatorTable.AS.createCall(p,
             ImmutableList.<SqlNode>builder()
@@ -238,16 +235,15 @@ public class SqlDdlNodes {
         .build();
     final Planner planner = Frameworks.getPlanner(config);
     try {
-      final StringWriter sw = new StringWriter();
-      final PrintWriter pw = new PrintWriter(sw);
-      final SqlPrettyWriter w =
-          new SqlPrettyWriter(CalciteSqlDialect.DEFAULT, false, pw);
-      pw.print("INSERT INTO ");
+      final StringBuilder buf = new StringBuilder();
+      final SqlWriterConfig writerConfig =
+          SqlPrettyWriter.config().withAlwaysUseParentheses(false);
+      final SqlPrettyWriter w = new SqlPrettyWriter(writerConfig, buf);
+      buf.append("INSERT INTO ");
       name.unparse(w, 0, 0);
-      pw.print(" ");
+      buf.append(' ');
       query.unparse(w, 0, 0);
-      pw.flush();
-      final String sql = sw.toString();
+      final String sql = buf.toString();
       final SqlNode query1 = planner.parse(sql);
       final SqlNode query2 = planner.validate(query1);
       final RelRoot r = planner.rel(query2);