From 292c6353793a3a4e8933094ebec444262396170e Mon Sep 17 00:00:00 2001 From: minleejae Date: Sun, 20 Sep 2026 20:54:02 +0900 Subject: [PATCH] feat: parse MySQL table option lists and UNION sources Signed-off-by: minleejae --- .../statement/create/table/TableOption.java | 40 +++++++- .../util/TableDefinitionTraversal.java | 7 ++ .../net/sf/jsqlparser/parser/JSqlParserCC.jjt | 73 ++++++++++---- .../create/MySqlTableOptionsTest.java | 96 +++++++++++++++++++ 4 files changed, 193 insertions(+), 23 deletions(-) create mode 100644 src/test/java/net/sf/jsqlparser/statement/create/MySqlTableOptionsTest.java 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 dda98b2768..239e616629 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 @@ -15,12 +15,13 @@ import java.util.Collections; import java.util.List; import net.sf.jsqlparser.statement.select.PlainSelect; +import net.sf.jsqlparser.schema.Table; /** A structured option following a {@code CREATE TABLE} definition. */ public class TableOption implements Serializable { public enum Kind { - ENGINE, CHARACTER_SET, COLLATE, COMMENT, AUTO_INCREMENT, OTHER + ENGINE, CHARACTER_SET, COLLATE, COMMENT, AUTO_INCREMENT, STATS_AUTO_RECALC, STATS_PERSISTENT, STATS_SAMPLE_PAGES, UNION, OTHER } private Kind kind = Kind.OTHER; @@ -28,6 +29,7 @@ public enum Kind { private String value; private boolean useEquals; private List tokens; + private List unionTables; public TableOption() {} @@ -54,6 +56,9 @@ public Kind getKind() { public void setKind(Kind kind) { this.kind = kind; + if (kind != Kind.UNION) { + unionTables = null; + } } public String getName() { @@ -65,11 +70,31 @@ public void setName(String name) { } public String getValue() { - return value; + return unionTables == null ? value : PlainSelect.getStringList(unionTables, true, true); } public void setValue(String value) { this.value = value; + unionTables = null; + } + + /** Returns the mutable MERGE table sources, including an empty UNION list. */ + public List
getUnionTables() { + return unionTables; + } + + /** Replaces raw option contents with structured UNION table references. */ + public void setUnionTables(List
unionTables) { + this.unionTables = unionTables; + kind = Kind.UNION; + name = "UNION"; + value = null; + tokens = null; + } + + public TableOption withUnionTables(List
unionTables) { + setUnionTables(unionTables); + return this; } public boolean isUseEquals() { @@ -92,14 +117,18 @@ public List getTokens() { if (useEquals) { result.add("="); } - if (value != null) { - result.add(value); + String renderedValue = getValue(); + if (renderedValue != null) { + result.add(renderedValue); } return Collections.unmodifiableList(result); } public void setTokens(List tokens) { this.tokens = tokens; + if (tokens != null) { + unionTables = null; + } } public TableOption withKind(Kind kind) { @@ -127,6 +156,7 @@ public String toString() { if (tokens != null) { return PlainSelect.getStringList(tokens, false, false); } - return name + (value != null ? (useEquals ? " = " : " ") + value : ""); + String renderedValue = getValue(); + return name + (renderedValue != null ? (useEquals ? " = " : " ") + renderedValue : ""); } } diff --git a/src/main/java/net/sf/jsqlparser/util/TableDefinitionTraversal.java b/src/main/java/net/sf/jsqlparser/util/TableDefinitionTraversal.java index f6883922ec..61e98a1bca 100644 --- a/src/main/java/net/sf/jsqlparser/util/TableDefinitionTraversal.java +++ b/src/main/java/net/sf/jsqlparser/util/TableDefinitionTraversal.java @@ -97,6 +97,13 @@ public static void visit(CreateTable table, Consumer expressions, table.getIndexes().forEach(index -> visit(index, expressions, tables)); } } + if (table.getTableOptions() != null) { + table.getTableOptions().forEach(option -> { + if (option.getUnionTables() != null) { + option.getUnionTables().forEach(source -> accept(source, tables)); + } + }); + } accept(table.getTrailingLikeTable(), tables); accept(table.getPartitionOf(), tables); visit(table.getPartitioning(), expressions); diff --git a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt index 5e6d9c46ed..e3a27d2ca8 100644 --- a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt +++ b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt @@ -1515,10 +1515,18 @@ public class CCJSqlParser extends AbstractJSqlParser { || "\"XML\"".equalsIgnoreCase(name); } + private boolean isMySqlStatisticsOptionAhead() { + String name = getToken(1).image; + return "STATS_AUTO_RECALC".equalsIgnoreCase(name) + || "STATS_PERSISTENT".equalsIgnoreCase(name) + || "STATS_SAMPLE_PAGES".equalsIgnoreCase(name); + } + private boolean isMySqlTableOptionAhead() { int kind = getToken(1).kind; if (kind == K_ENGINE || kind == K_COLLATE || kind == K_COMMENT - || kind == K_AUTO_INCREMENT) { + || kind == K_AUTO_INCREMENT || kind == K_UNION + || isMySqlStatisticsOptionAhead()) { return true; } if (kind == K_CHARACTER && getToken(2).kind == K_SET @@ -14803,7 +14811,6 @@ CreateTable CreateTable(boolean isUsingOrReplace): Table table = null; List tableElements = new ArrayList(); TableElement element; - List tableOptions = new ArrayList(); List typedTableOptions = new ArrayList(); List createOptions = new ArrayList(); Token tk = null; @@ -14861,21 +14868,7 @@ CreateTable CreateTable(boolean isUsingOrReplace): [ LOOKAHEAD({ partitionOfTable != null }) partitionBound=PostgreSqlPartitionBound() { createTable.setPartitionBound(partitionBound); } ] - ( LOOKAHEAD(2, { getToken(1).kind != K_AS - && !(getToken(1).kind == K_PARTITION && getToken(2).kind == K_BY) }) - ( - LOOKAHEAD({ isMySqlTableOptionAhead() }) - tableOption=MySqlTableOption() { - typedTableOptions.add(tableOption); - tableOptions.addAll(tableOption.getTokens()); - } - | - parameter=CreateParameter() { - typedTableOptions.add(TableOption.raw(parameter)); - tableOptions.addAll(parameter); - } - ) - )* + CreateTableOptions(typedTableOptions) [ partitioning=CreateTablePartitioning() { createTable.setPartitioning(partitioning); } ] // see https://docs.oracle.com/cd/B19306_01/server.102/b14200/statements_7002.htm#i2126725 @@ -14935,16 +14928,59 @@ ColumnDefinition CreateTableColumnDefinition(boolean typed): { if (true) { return column; } } } +/** Reads complete options and keeps commas separate from legacy raw token groups. */ +void CreateTableOptions(List options): +{ TableOption option; } +{ + [ LOOKAHEAD(2, { getToken(1).kind != K_AS + && !(getToken(1).kind == K_PARTITION && getToken(2).kind == K_BY) }) + option=CreateTableOption() { options.add(option); } + ( LOOKAHEAD(2, { getToken(1).kind != K_AS + && !(getToken(1).kind == K_PARTITION && getToken(2).kind == K_BY) + && !(getToken(1).kind == K_COMMA + && "INTERLEAVE".equalsIgnoreCase(getToken(2).image)) }) + [ "," ] option=CreateTableOption() { options.add(option); } + )* + ] +} + +TableOption CreateTableOption(): +{ TableOption option; List tokens; } +{ + ( LOOKAHEAD({ isMySqlTableOptionAhead() }) option=MySqlTableOption() + | tokens=CreateParameter() { option = TableOption.raw(tokens); } ) + { return option; } +} + TableOption MySqlTableOption(): { Token tk; Token tk2 = null; - String value; + String value = null; + List
unionTables = null; + Table unionTable; boolean useEquals = false; String name = ""; TableOption.Kind kind = null; TableOption option; } { ( + tk= { name = tk.image; kind = TableOption.Kind.UNION; + unionTables = new ArrayList
(); } + [ "=" { useEquals = true; } ] + "(" [ unionTable=Table() { unionTables.add(unionTable); } + ( "," unionTable=Table() { unionTables.add(unionTable); } )* ] ")" + | + LOOKAHEAD({ isMySqlStatisticsOptionAhead() }) tk= { + name = tk.image; + kind = TableOption.Kind.valueOf(name.toUpperCase(Locale.ROOT)); + } + [ "=" { useEquals = true; } ] + ( tk= | tk= ) { value = tk.image; + requireDdlSyntax(kind == TableOption.Kind.STATS_SAMPLE_PAGES + || "DEFAULT".equalsIgnoreCase(value) || "0".equals(value) || "1".equals(value), + "Expected DEFAULT, 0, or 1 for " + name); + } + | tk= { name = tk.image; kind = TableOption.Kind.ENGINE; @@ -14989,6 +15025,7 @@ TableOption MySqlTableOption(): { ) { option = new TableOption(kind, name, value, useEquals); + if (unionTables != null) { option.setUnionTables(unionTables); } return option; } } diff --git a/src/test/java/net/sf/jsqlparser/statement/create/MySqlTableOptionsTest.java b/src/test/java/net/sf/jsqlparser/statement/create/MySqlTableOptionsTest.java new file mode 100644 index 0000000000..bbfab5388b --- /dev/null +++ b/src/test/java/net/sf/jsqlparser/statement/create/MySqlTableOptionsTest.java @@ -0,0 +1,96 @@ +/*- + * #%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.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import net.sf.jsqlparser.JSQLParserException; +import net.sf.jsqlparser.parser.AbstractJSqlParser.Dialect; +import net.sf.jsqlparser.parser.CCJSqlParserUtil; +import net.sf.jsqlparser.statement.create.table.CreateTable; +import net.sf.jsqlparser.statement.create.table.TableOption; +import net.sf.jsqlparser.util.TablesNamesFinder; +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 MySqlTableOptionsTest { + @ParameterizedTest + @ValueSource(strings = { + "STATS_PERSISTENT=1, STATS_AUTO_RECALC=1", + "STATS_PERSISTENT DEFAULT, STATS_AUTO_RECALC=0 STATS_SAMPLE_PAGES=32", + "ENGINE=InnoDB, DEFAULT CHARSET=utf8mb4", + "ENGINE=InnoDB, COMMENT='commas, in text', AUTO_INCREMENT=10", + "UNION=(t1,t2) ENGINE=MRG_MyISAM", + "ENGINE=MRG_MyISAM, UNION (db.t1, `db`.`t2`)", + "UNION=() ENGINE=MRG_MyISAM", + "ENGINE=InnoDB, STATS_PERSISTENT=1 PARTITION BY HASH(id) PARTITIONS 2", + "ENGINE=InnoDB, STATS_AUTO_RECALC=DEFAULT AS SELECT 1 AS other" + }) + void parsesOptionSeparatorsAndValues(String options) throws JSQLParserException { + CreateTable table = parse("CREATE TABLE t (id INT) " + options); + assertRoundTrip(table); + assertEquals(table.toString(), CCJSqlParserUtil.parse(table.toString()).toString()); + } + + @Test + void statisticsOptionsHaveIndependentValues() throws JSQLParserException { + CreateTable table = parse("CREATE TABLE t (id INT) " + + "STATS_PERSISTENT=1, STATS_AUTO_RECALC=DEFAULT"); + TableOption persistent = + table.getTableOption(TableOption.Kind.STATS_PERSISTENT).orElseThrow(); + assertEquals("1", persistent.getValue()); + persistent.setValue("0"); + assertThat(table.toString()).contains("STATS_PERSISTENT = 0 STATS_AUTO_RECALC = DEFAULT"); + assertRoundTrip(table); + } + + @Test + void unionSourcesAreTraversableAndMutable() throws JSQLParserException { + CreateTable table = + parse("CREATE TABLE merged (id INT) UNION=(db.t1,t2) ENGINE=MRG_MyISAM"); + TableOption union = table.getTableOption(TableOption.Kind.UNION).orElseThrow(); + assertEquals(2, union.getUnionTables().size()); + assertEquals("db", union.getUnionTables().get(0).getSchemaName()); + union.getUnionTables().get(0).setName("changed"); + assertEquals("(db.changed, t2)", union.getValue()); + assertThat(table.getTableOptionsStrings()).contains("(db.changed, t2)"); + assertThat(new TablesNamesFinder().getTables(table)) + .containsExactlyInAnyOrder("merged", "db.changed", "t2"); + assertRoundTrip(table); + union.getUnionTables().clear(); + assertEquals("()", union.getValue()); + assertRoundTrip(table); + union.setValue("(replacement)"); + assertEquals("(replacement)", union.getValue()); + assertRoundTrip(table); + } + + @ParameterizedTest + @ValueSource(strings = {", ENGINE=InnoDB", "ENGINE=InnoDB,", "ENGINE=InnoDB,, COMMENT='x'", + "UNION=(t1,)", "UNION=", "STATS_PERSISTENT=2", "STATS_AUTO_RECALC=-1"}) + void rejectsIncompleteListsAndInvalidBooleanOptions(String options) { + assertThrows(JSQLParserException.class, () -> parse("CREATE TABLE t (id INT) " + options)); + } + + private static CreateTable parse(String sql) throws JSQLParserException { + return (CreateTable) CCJSqlParserUtil.parse(sql, p -> p.withDialect(Dialect.MYSQL)); + } + + private static void assertRoundTrip(CreateTable table) throws JSQLParserException { + StringBuilder buffer = new StringBuilder(); + table.accept(new StatementDeParser(buffer), null); + assertEquals(table.toString(), buffer.toString()); + assertEquals(table.toString(), parse(buffer.toString()).toString()); + } +}