diff --git a/src/main/java/net/sf/jsqlparser/statement/create/table/CreateTable.java b/src/main/java/net/sf/jsqlparser/statement/create/table/CreateTable.java index 2496f816d..86d2d9bbb 100644 --- a/src/main/java/net/sf/jsqlparser/statement/create/table/CreateTable.java +++ b/src/main/java/net/sf/jsqlparser/statement/create/table/CreateTable.java @@ -25,6 +25,29 @@ public class CreateTable implements Statement { + private boolean tableOptionsAfterPartition; + + public boolean isTableOptionsAfterPartition() { + return tableOptionsAfterPartition; + } + + public void setTableOptionsAfterPartition(boolean tableOptionsAfterPartition) { + this.tableOptionsAfterPartition = tableOptionsAfterPartition; + } + + private net.sf.jsqlparser.statement.execute.Execute execute; + + /** Prepared statement source of PostgreSQL CREATE TABLE AS EXECUTE. */ + public net.sf.jsqlparser.statement.execute.Execute getExecute() { + return execute; + } + + public void setExecute(net.sf.jsqlparser.statement.execute.Execute execute) { + this.execute = execute; + select = null; + selectParenthesis = false; + } + private Table table; private boolean unlogged = false; private List createOptionsStrings; @@ -231,7 +254,13 @@ public void setWithData(Boolean withData) { public StringBuilder appendSelectTo(StringBuilder builder, java.util.function.Consumer selectRenderer, + java.util.function.Consumer executeRenderer) { + if (select != null || execute != null) { if (duplicateHandling != null) { builder.append(' ').append(duplicateHandling); } @@ -239,7 +268,11 @@ public StringBuilder appendSelectTo(StringBuilder builder, if (selectParenthesis) { builder.append("("); } - selectRenderer.accept(select); + if (execute != null) { + executeRenderer.accept(execute); + } else { + selectRenderer.accept(select); + } if (selectParenthesis) { builder.append(")"); } @@ -252,6 +285,7 @@ public StringBuilder appendSelectTo(StringBuilder builder, public void setSelect(Select select, boolean parenthesis) { this.select = select; + this.execute = null; this.selectParenthesis = parenthesis; } @@ -449,14 +483,27 @@ private void appendColumnDefinitions(StringBuilder b) { } } + public void appendTableOptionsTo(StringBuilder builder, + java.util.function.Consumer expressionPrinter) { + if (tableOptions != null) { + for (TableOption option : tableOptions) { + builder.append(' '); + option.appendTo(builder, expressionPrinter); + } + } else { + String options = PlainSelect.getStringList(tableOptionsStrings, false, false); + if (options != null && !options.isEmpty()) { + builder.append(' ').append(options); + } + } + } + private void appendTableOptions(StringBuilder b) { - String options = tableOptions != null - ? PlainSelect.getStringList(tableOptions, false, false) - : PlainSelect.getStringList(tableOptionsStrings, false, false); - if (options != null && options.length() > 0) { - b.append(" ").append(options); + if (partitioning != null && tableOptionsAfterPartition) { + b.append(" ").append(partitioning); } - if (partitioning != null) { + appendTableOptionsTo(b, b::append); + if (partitioning != null && !tableOptionsAfterPartition) { b.append(" ").append(partitioning); } } diff --git a/src/main/java/net/sf/jsqlparser/statement/create/table/TableOption.java b/src/main/java/net/sf/jsqlparser/statement/create/table/TableOption.java index 8dffe815c..1f2104b9f 100644 --- a/src/main/java/net/sf/jsqlparser/statement/create/table/TableOption.java +++ b/src/main/java/net/sf/jsqlparser/statement/create/table/TableOption.java @@ -21,7 +21,8 @@ public class TableOption implements Serializable { public enum Kind { - ENGINE, CHARACTER_SET, COLLATE, COMMENT, AUTO_INCREMENT, STATS_AUTO_RECALC, STATS_PERSISTENT, STATS_SAMPLE_PAGES, UNION, ENCRYPTION, PASSWORD, DATA_DIRECTORY, INDEX_DIRECTORY, ENGINE_ATTRIBUTE, SECONDARY_ENGINE_ATTRIBUTE, OTHER + ENGINE, CHARACTER_SET, COLLATE, COMMENT, AUTO_INCREMENT, STATS_AUTO_RECALC, STATS_PERSISTENT, STATS_SAMPLE_PAGES, UNION, ENCRYPTION, PASSWORD, DATA_DIRECTORY, INDEX_DIRECTORY, + STORAGE_PARAMETERS, WITHOUT_OIDS, ENGINE_ATTRIBUTE, SECONDARY_ENGINE_ATTRIBUTE, OTHER } private Kind kind = Kind.OTHER; @@ -30,6 +31,31 @@ public enum Kind { private boolean useEquals; private List tokens; private List unionTables; + private List storageParameters; + + public List getStorageParameters() { + return storageParameters; + } + + public void setStorageParameters(List storageParameters) { + this.storageParameters = storageParameters; + kind = Kind.STORAGE_PARAMETERS; + name = "WITH"; + value = null; + tokens = null; + unionTables = null; + } + + public void appendTo(StringBuilder builder, + java.util.function.Consumer expressionPrinter) { + if (storageParameters != null) { + builder.append("WITH "); + Index.Option.appendListTo(builder, storageParameters, expressionPrinter); + } else { + builder.append(toString()); + } + } + public TableOption() {} @@ -56,6 +82,9 @@ public Kind getKind() { public void setKind(Kind kind) { this.kind = kind; + if (kind != Kind.STORAGE_PARAMETERS) { + storageParameters = null; + } if (kind != Kind.UNION) { unionTables = null; } @@ -70,12 +99,16 @@ public void setName(String name) { } public String getValue() { + if (storageParameters != null) { + return PlainSelect.getStringList(storageParameters, true, true); + } return unionTables == null ? value : PlainSelect.getStringList(unionTables, true, true); } public void setValue(String value) { this.value = value; unionTables = null; + storageParameters = null; } /** Returns the mutable MERGE table sources, including an empty UNION list. */ @@ -90,6 +123,7 @@ public void setUnionTables(List
unionTables) { name = "UNION"; value = null; tokens = null; + storageParameters = null; } public TableOption withUnionTables(List
unionTables) { @@ -128,6 +162,7 @@ public void setTokens(List tokens) { this.tokens = tokens; if (tokens != null) { unionTables = null; + storageParameters = null; } } diff --git a/src/main/java/net/sf/jsqlparser/statement/create/table/TablePartitioning.java b/src/main/java/net/sf/jsqlparser/statement/create/table/TablePartitioning.java index 1c96a39ea..5a287f9b4 100644 --- a/src/main/java/net/sf/jsqlparser/statement/create/table/TablePartitioning.java +++ b/src/main/java/net/sf/jsqlparser/statement/create/table/TablePartitioning.java @@ -35,6 +35,20 @@ public enum Type { EXPRESSION } + private List keyColumns; + + /** PostgreSQL keys carrying collation/operator-class attributes, otherwise null. */ + public List getKeyColumns() { + return keyColumns; + } + + public void setKeyColumns(List keyColumns) { + this.keyColumns = keyColumns; + expression = null; + expressionList = null; + columns = null; + } + private Type type; private boolean linear; private boolean columnsSyntax; @@ -84,6 +98,7 @@ public Expression getExpression() { public void setExpression(Expression expression) { this.expression = expression; + keyColumns = null; } /** @@ -97,6 +112,7 @@ public ExpressionList getExpressionList() { public void setExpressionList(ExpressionList expressionList) { this.expressionList = expressionList; + keyColumns = null; } public ExpressionList getColumns() { @@ -105,6 +121,7 @@ public ExpressionList getColumns() { public void setColumns(ExpressionList columns) { this.columns = columns; + keyColumns = null; } public Integer getAlgorithm() { @@ -305,7 +322,16 @@ private void appendMethod(StringBuilder builder, Consumer expression if (columnsSyntax) { builder.append(" COLUMNS"); } - if (expression != null) { + if (keyColumns != null) { + builder.append(" ("); + for (int i = 0; i < keyColumns.size(); i++) { + if (i > 0) { + builder.append(", "); + } + keyColumns.get(i).appendTo(builder, expressionPrinter); + } + builder.append(')'); + } else if (expression != null) { builder.append(" ("); expressionPrinter.accept(expression); builder.append(')'); diff --git a/src/main/java/net/sf/jsqlparser/util/TableDefinitionTraversal.java b/src/main/java/net/sf/jsqlparser/util/TableDefinitionTraversal.java index 5c6bfe7d1..d3fc323c1 100644 --- a/src/main/java/net/sf/jsqlparser/util/TableDefinitionTraversal.java +++ b/src/main/java/net/sf/jsqlparser/util/TableDefinitionTraversal.java @@ -104,6 +104,7 @@ public static void visit(CreateTable table, Consumer expressions, } if (table.getTableOptions() != null) { table.getTableOptions().forEach(option -> { + visitOptions(option.getStorageParameters(), expressions); if (option.getUnionTables() != null) { option.getUnionTables().forEach(source -> accept(source, tables)); } @@ -112,6 +113,9 @@ public static void visit(CreateTable table, Consumer expressions, if (table.getInherits() != null) { table.getInherits().forEach(parent -> accept(parent, tables)); } + if (table.getExecute() != null && table.getExecute().getExprList() != null) { + accept(table.getExecute().getExprList(), expressions); + } accept(table.getTrailingLikeTable(), tables); accept(table.getPartitionOf(), tables); visit(table.getPartitioning(), expressions); @@ -123,7 +127,12 @@ public static void visit(TablePartitioning partitioning, Consumer ex if (partitioning == null) { return; } - if (partitioning.getExpression() != null) { + if (partitioning.getKeyColumns() != null) { + partitioning.getKeyColumns().forEach(key -> { + accept(key.getExpression(), expressions); + visitOptions(key.getOperatorClassParameters(), expressions); + }); + } else if (partitioning.getExpression() != null) { accept(partitioning.getExpression(), expressions); } else if (partitioning.getExpressionList() != null) { accept(partitioning.getExpressionList(), expressions); diff --git a/src/main/java/net/sf/jsqlparser/util/deparser/CreateTableDeParser.java b/src/main/java/net/sf/jsqlparser/util/deparser/CreateTableDeParser.java index 4ca8c4425..8c504e512 100644 --- a/src/main/java/net/sf/jsqlparser/util/deparser/CreateTableDeParser.java +++ b/src/main/java/net/sf/jsqlparser/util/deparser/CreateTableDeParser.java @@ -114,11 +114,15 @@ public void deParse(CreateTable createTable) { null)); } - params = PlainSelect.getStringList(createTable.getTableOptionsStrings(), false, false); - if (!"".equals(params)) { - builder.append(' ').append(params); + if (createTable.getPartitioning() != null && createTable.isTableOptionsAfterPartition()) { + builder.append(' '); + createTable.getPartitioning().appendTo(builder, + expression -> expression.accept(statementDeParser.getExpressionDeParser(), + null)); } - if (createTable.getPartitioning() != null) { + createTable.appendTableOptionsTo(builder, + expression -> expression.accept(statementDeParser.getExpressionDeParser(), null)); + if (createTable.getPartitioning() != null && !createTable.isTableOptionsAfterPartition()) { builder.append(' '); createTable.getPartitioning().appendTo(builder, expression -> expression.accept(statementDeParser.getExpressionDeParser(), @@ -129,7 +133,8 @@ public void deParse(CreateTable createTable) { builder.append(' ').append(createTable.getRowMovement().getMode().toString()) .append(" ROW MOVEMENT"); } - createTable.appendSelectTo(builder, select -> select.accept(this.statementDeParser, null)); + createTable.appendQueryTo(builder, select -> select.accept(this.statementDeParser, null), + execute -> execute.accept(this.statementDeParser, null)); if (createTable.getTrailingLikeTable() != null) { builder.append(" LIKE "); if (createTable.isSelectParenthesis()) { diff --git a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt index 187c7b557..8b06c80eb 100644 --- a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt +++ b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt @@ -12803,7 +12803,7 @@ ExpressionList ExecuteArguments(): { LOOKAHEAD("(") arguments=ExpressionList() | argument=ExecuteArgument() { arguments.add(argument); } - ( "," argument=ExecuteArgument() { arguments.add(argument); } )* + ( LOOKAHEAD(2) "," argument=ExecuteArgument() { arguments.add(argument); } )* ) { return arguments; } } @@ -13913,10 +13913,19 @@ Index.Option PostgreSqlIndexOption(): boolean useEquals = false; } { - name=RelObjectName() [ "=" { useEquals = true; } ] value=Expression() + name=PostgreSqlStorageParameterName() [ "=" { useEquals = true; } ] value=Expression() { return new Index.Option(name, value, useEquals); } } +/** Storage parameters can have namespaces, such as toast.autovacuum_enabled. */ +String PostgreSqlStorageParameterName(): +{ String name; String part; } +{ + name=RelObjectName() + ( LOOKAHEAD(".") "." part=RelObjectNameExt() { name += "." + part; } )* + { return name; } +} + List PostgreSqlIndexOptions(): { List options = new ArrayList(); @@ -15162,6 +15171,8 @@ CreateTable CreateTable(boolean isUsingOrReplace): TableOption tableOption = null; SpannerInterleaveIn interleaveIn = null; Select select = null; + Execute execute; + int optionCount; Table likeTable = null; RowMovement rowMovement = null; String tableColumn = null; @@ -15180,7 +15191,7 @@ CreateTable CreateTable(boolean isUsingOrReplace): [ { createTable.setUnlogged(true); } ] // table options, not required but 1 or none - [ tk= { createOptions.add(tk.image);} ] + [ ( tk= | tk= ) { createOptions.add(tk.image);} ] (parameter = CreateParameter() { createOptions.addAll(parameter); })* @@ -15227,7 +15238,11 @@ CreateTable CreateTable(boolean isUsingOrReplace): partitionBound=PostgreSqlPartitionBound() { createTable.setPartitionBound(partitionBound); } ] CreateTableOptions(typedTableOptions) - [ partitioning=CreateTablePartitioning() { createTable.setPartitioning(partitioning); } ] + [ LOOKAHEAD( ) partitioning=CreateTablePartitioning() + { createTable.setPartitioning(partitioning); } ] + { optionCount = typedTableOptions.size(); } + CreateTableOptions(typedTableOptions) + { createTable.setTableOptionsAfterPartition(typedTableOptions.size() > optionCount); } // see https://docs.oracle.com/cd/B19306_01/server.102/b14200/statements_7002.htm#i2126725 // table properties , all these are optional @@ -15238,8 +15253,16 @@ CreateTable CreateTable(boolean isUsingOrReplace): { createTable.setDuplicateHandling(CreateTable.DuplicateHandling.valueOf( tk.image.toUpperCase(Locale.ROOT))); } ] [ { createTable.setUseAsKeyword(true); } ] - ( LOOKAHEAD() select=TableStatement() | select=Select() ) - { createTable.setSelect(select, false); } + ( + LOOKAHEAD() execute=Execute() { + requireDdlSyntax(createTable.isUseAsKeyword() && createTable.getDuplicateHandling() == null, + "CREATE TABLE EXECUTE requires AS and cannot use duplicate handling"); + createTable.setExecute(execute); + } + | + ( LOOKAHEAD() select=TableStatement() | select=Select() ) + { createTable.setSelect(select, false); } + ) [ LOOKAHEAD(2) withData=DataPopulation() { createTable.setWithData(withData); } ] ] @@ -15321,9 +15344,18 @@ void CreateTableOptions(List options): } TableOption CreateTableOption(): -{ TableOption option; List tokens; } +{ TableOption option; List tokens; List storageParameters; } { - ( LOOKAHEAD({ isMySqlTableOptionAhead() }) option=MySqlTableOption() + ( + LOOKAHEAD( "(") storageParameters=PostgreSqlIndexOptions() { + option = new TableOption(); option.setStorageParameters(storageParameters); + } + | + LOOKAHEAD( ) ContextualKeyword("OIDS") { + option = new TableOption(TableOption.Kind.WITHOUT_OIDS, "WITHOUT OIDS", null, false); + } + | + LOOKAHEAD({ isMySqlTableOptionAhead() }) option=MySqlTableOption() | tokens=CreateParameter() { option = TableOption.raw(tokens); } ) { return option; } } @@ -16900,6 +16932,9 @@ TablePartitioning CreateTablePartitioning(): { ( + LOOKAHEAD({ Dialect.POSTGRESQL.name().equals(getAsString(Feature.dialect)) }) + partitioning=PostgreSqlPartitionKey() + | // explicit lookaheads: the method keywords win over the BigQuery // bare-expression alternative below, which they share a prefix with LOOKAHEAD(2) partitioning=HashOrKeyPartitioning() @@ -16961,7 +16996,8 @@ TablePartitioning CreateTablePartitioning(): ] [ LOOKAHEAD(2) partitionDefinitions=PartitionDefinitions() { partitioning.setPartitionDefinitions(partitionDefinitions); } ] - ( LOOKAHEAD(2, { !isCreateTableQueryPrefix() }) + ( LOOKAHEAD(2, { !isCreateTableQueryPrefix() + && !Dialect.POSTGRESQL.name().equals(getAsString(Feature.dialect)) }) parameter=CreateParameter() { partitionOptions.addAll(parameter); } )* { if (!partitionOptions.isEmpty()) { @@ -16971,6 +17007,39 @@ TablePartitioning CreateTablePartitioning(): } } +/** Shares collation/opclass nodes with index keys, excluding index-only ordering. */ +TablePartitioning PostgreSqlPartitionKey(): +{ + TablePartitioning result; + Token method; + List keys; + ExpressionList expressions = new ExpressionList(); + boolean attributes = false; +} +{ + ( method= | method= | method= ) + keys=IndexKeyColumns(false) + { + result = new TablePartitioning(TablePartitioning.Type.valueOf(method.image.toUpperCase(Locale.ROOT))); + for (Index.ColumnParams key : keys) { + requireDdlSyntax(key.getSortOrder() == null && key.getNullOrdering() == null + && key.getOperatorClassParameters() == null, + "Partition keys do not accept index ordering or operator-class parameters"); + attributes |= key.getCollation() != null || key.getOperatorClass() != null; + Expression expression = key.getExpression(); + if (expression == null) { expression = createColumn(key.getColumnName()); } + else if (key.isExpressionParenthesized()) { + expression = new ParenthesedExpressionList(expression); + } + expressions.add(expression); + } + if (attributes) { result.setKeyColumns(keys); } + else if (expressions.size() == 1) { result.setExpression(expressions.get(0)); } + else { result.setExpressionList(expressions); } + return result; + } +} + PartitionBound PostgreSqlPartitionBound(): { PartitionBound bound = null; diff --git a/src/test/java/net/sf/jsqlparser/statement/create/PostgreSqlTableQueryOptionsTest.java b/src/test/java/net/sf/jsqlparser/statement/create/PostgreSqlTableQueryOptionsTest.java new file mode 100644 index 000000000..0f128ab50 --- /dev/null +++ b/src/test/java/net/sf/jsqlparser/statement/create/PostgreSqlTableQueryOptionsTest.java @@ -0,0 +1,123 @@ +/*- + * #%L + * JSQLParser library + * %% + * Copyright (C) 2004 - 2026 JSQLParser + * %% + * Dual licensed under GNU LGPL 2.1 or Apache License 2.0 + * #L% + */ +package net.sf.jsqlparser.statement.create; + +import static org.junit.jupiter.api.Assertions.*; +import java.util.ArrayList; +import java.util.List; +import net.sf.jsqlparser.JSQLParserException; +import net.sf.jsqlparser.expression.Expression; +import net.sf.jsqlparser.expression.LongValue; +import net.sf.jsqlparser.parser.AbstractJSqlParser.Dialect; +import net.sf.jsqlparser.parser.CCJSqlParserUtil; +import net.sf.jsqlparser.statement.create.table.*; +import net.sf.jsqlparser.util.TableDefinitionTraversal; +import net.sf.jsqlparser.util.deparser.StatementDeParser; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +class PostgreSqlTableQueryOptionsTest { + @ParameterizedTest + @ValueSource(strings = { + "CREATE LOCAL TEMP TABLE t (id INT)", + "CREATE LOCAL TEMPORARY TABLE t(a INT) ON COMMIT DROP", + "CREATE LOCAL TEMP TABLE t AS SELECT 1 AS id WITH NO DATA", + "CREATE LOCAL TEMPORARY TABLE t (id INT)", + "CREATE LOCAL TEMPORARY TABLE t AS SELECT 1 AS id WITH NO DATA", + "CREATE TABLE t (id INT, s TEXT) WITH (toast.autovacuum_enabled=false)", + "CREATE TABLE t(a TEXT) WITH(toast.autovacuum_enabled=false,fillfactor=80)", + "CREATE TABLE t (id INT, s TEXT) WITHOUT OIDS", + "CREATE TABLE t(a INT) WITHOUT OIDS", + "CREATE TABLE t (id INT, s TEXT) PARTITION BY RANGE(id int4_ops)", + "CREATE TABLE t(a INT,label TEXT) PARTITION BY RANGE(a int4_ops)", + "CREATE TABLE t(a INT,label TEXT) PARTITION BY RANGE(label COLLATE \"C\" text_ops)", + "CREATE TABLE t AS EXECUTE prepared_source(1)", + "CREATE TABLE t AS EXECUTE q(1) WITH NO DATA"}) + void auditedFormsRoundTrip(String sql) throws JSQLParserException { + CreateTable table = parse(sql); + assertRoundTrip(table); + assertEquals(2, CCJSqlParserUtil.parseStatements(sql + "; SELECT 1", + p -> p.withDialect(Dialect.POSTGRESQL)).size()); + } + + @Test + void storageParametersStayMutableAndVisibleToVisitors() throws JSQLParserException { + CreateTable table = parse("CREATE TABLE t (id INT) WITH (toast.autovacuum_enabled=false)"); + TableOption option = + table.getTableOption(TableOption.Kind.STORAGE_PARAMETERS).orElseThrow(); + Index.Option parameter = option.getStorageParameters().get(0); + assertEquals("toast.autovacuum_enabled", parameter.getName()); + parameter.setName("fillfactor"); + parameter.setValue(new LongValue(70)); + assertEquals("CREATE TABLE t (id INT) WITH (fillfactor = 70)", table.toString()); + List visited = new ArrayList<>(); + TableDefinitionTraversal.visit(table, visited::add, ignored -> { + }); + assertEquals(List.of(parameter.getValue()), visited); + assertRoundTrip(table); + } + + @Test + void partitionAttributesShareIndexKeyNodes() throws JSQLParserException { + CreateTable table = parse( + "CREATE TABLE t (id INT, other_id INT) PARTITION BY RANGE (id pg_catalog.int4_ops)"); + Index.ColumnParams key = table.getPartitioning().getKeyColumns().get(0); + assertEquals("pg_catalog.int4_ops", key.getOperatorClass()); + table.getPartitioning().getKeyColumns().set(0, + new Index.ColumnParams("other_id").withOperatorClass("int4_ops")); + assertTrue(table.toString().endsWith("PARTITION BY RANGE (other_id int4_ops)")); + assertRoundTrip(table); + } + + @Test + void partitionPrecedesTableOptions() throws JSQLParserException { + CreateTable table = + parse("CREATE TABLE t (id INT) PARTITION BY RANGE (id int4_ops) WITHOUT OIDS"); + assertTrue(table.isTableOptionsAfterPartition()); + assertEquals("CREATE TABLE t (id INT) PARTITION BY RANGE (id int4_ops) WITHOUT OIDS", + table.toString()); + assertRoundTrip(table); + } + + @Test + void executeSourceAndArgumentsRemainStructured() throws JSQLParserException { + CreateTable table = parse("CREATE TABLE t AS EXECUTE q(1) WITH NO DATA"); + assertNull(table.getSelect()); + assertEquals("q", table.getExecute().getName()); + table.getExecute().setName("q2"); + table.getExecute().getExprList().set(0, new LongValue(2)); + assertEquals("CREATE TABLE t AS EXECUTE q2 (2) WITH NO DATA", table.toString()); + assertRoundTrip(table); + table.setSelect( + (net.sf.jsqlparser.statement.select.Select) CCJSqlParserUtil.parse("SELECT 1"), + false); + assertNull(table.getExecute()); + } + + @Test + void indexOnlyOrderingIsRejectedForPartitionKeys() { + assertThrows(JSQLParserException.class, + () -> parse("CREATE TABLE t (id INT) PARTITION BY RANGE (id DESC)")); + assertThrows(JSQLParserException.class, + () -> parse("CREATE TABLE t (id INT) PARTITION BY RANGE (id NULLS FIRST)")); + } + + private static CreateTable parse(String sql) throws JSQLParserException { + return (CreateTable) CCJSqlParserUtil.parse(sql, p -> p.withDialect(Dialect.POSTGRESQL)); + } + + private static void assertRoundTrip(CreateTable table) throws JSQLParserException { + StringBuilder sql = new StringBuilder(); + table.accept(new StatementDeParser(sql), null); + assertEquals(table.toString(), sql.toString()); + assertEquals(table.toString(), parse(sql.toString()).toString()); + } +}