From 96a664fa4af8f8abd88fe9370909721937886f84 Mon Sep 17 00:00:00 2001 From: minleejae Date: Wed, 23 Sep 2026 09:38:50 +0900 Subject: [PATCH] feat: model MySQL character set shorthands Signed-off-by: minleejae --- .../statement/create/table/ColDataType.java | 64 +++++++-- .../net/sf/jsqlparser/parser/JSqlParserCC.jjt | 12 +- .../table/CharacterSetShorthandTest.java | 123 ++++++++++++++++++ 3 files changed, 189 insertions(+), 10 deletions(-) create mode 100644 src/test/java/net/sf/jsqlparser/statement/create/table/CharacterSetShorthandTest.java diff --git a/src/main/java/net/sf/jsqlparser/statement/create/table/ColDataType.java b/src/main/java/net/sf/jsqlparser/statement/create/table/ColDataType.java index cc82907fe..6e18956e1 100644 --- a/src/main/java/net/sf/jsqlparser/statement/create/table/ColDataType.java +++ b/src/main/java/net/sf/jsqlparser/statement/create/table/ColDataType.java @@ -37,10 +37,15 @@ public enum NationalCharacterType { CHAR, VARCHAR } + /** Spelling of a character set clause; MySQL shorthands also select a character set. */ + public enum CharacterSetSyntax { + CHARACTER_SET, CHARSET, ASCII, UNICODE + } + private String dataType; private List argumentsStringList; private String characterSet; - private boolean useCharsetKeyword; + private CharacterSetSyntax characterSetSyntax = CharacterSetSyntax.CHARACTER_SET; private IntervalQualifier intervalQualifier; private List arrayData = new ArrayList(); private Signedness signedness; @@ -166,21 +171,51 @@ public void setDataType(List list) { dataType = list.stream().collect(joining(".")); } + /** Returns the character set name, resolving ASCII to latin1 and UNICODE to ucs2. */ public String getCharacterSet() { return characterSet; } public void setCharacterSet(String characterSet) { this.characterSet = characterSet; + if (characterSetSyntax == CharacterSetSyntax.ASCII + || characterSetSyntax == CharacterSetSyntax.UNICODE) { + characterSetSyntax = CharacterSetSyntax.CHARACTER_SET; + } + } + + public CharacterSetSyntax getCharacterSetSyntax() { + return characterSetSyntax; + } + + /** + * Selects the clause spelling. ASCII selects latin1 and UNICODE selects ucs2; the explicit + * spellings retain the current character set. Calling {@link #setCharacterSet(String)} after a + * shorthand switches to CHARACTER SET. + */ + public void setCharacterSetSyntax(CharacterSetSyntax characterSetSyntax) { + this.characterSetSyntax = Objects.requireNonNull(characterSetSyntax, "characterSetSyntax"); + if (characterSetSyntax == CharacterSetSyntax.ASCII) { + characterSet = "latin1"; + } else if (characterSetSyntax == CharacterSetSyntax.UNICODE) { + characterSet = "ucs2"; + } + } + + public ColDataType withCharacterSetSyntax(CharacterSetSyntax characterSetSyntax) { + setCharacterSetSyntax(characterSetSyntax); + return this; } /** Whether the character set clause uses MySQL's CHARSET abbreviation. */ public boolean isUseCharsetKeyword() { - return useCharsetKeyword; + return characterSetSyntax == CharacterSetSyntax.CHARSET; } public void setUseCharsetKeyword(boolean useCharsetKeyword) { - this.useCharsetKeyword = useCharsetKeyword; + setCharacterSetSyntax(useCharsetKeyword + ? CharacterSetSyntax.CHARSET + : CharacterSetSyntax.CHARACTER_SET); } public IntervalQualifier getIntervalQualifier() { @@ -348,9 +383,22 @@ public String toString() { : (signedness != null ? " " + signedness : "") + (zerofill ? " ZEROFILL" : "")) + arraySpec.toString() - + (characterSet != null - ? (useCharsetKeyword ? " CHARSET " : " CHARACTER SET ") + characterSet - : ""); + + characterSetClause(); + } + + private String characterSetClause() { + if (characterSet == null) { + return ""; + } + switch (characterSetSyntax) { + case ASCII: + case UNICODE: + return " " + characterSetSyntax; + case CHARSET: + return " CHARSET " + characterSet; + default: + return " CHARACTER SET " + characterSet; + } } public ColDataType withDataType(String dataType) { @@ -447,7 +495,7 @@ public final boolean equals(Object o) { return dataType.equalsIgnoreCase(that.dataType) && Objects.equals(argumentsStringList, that.argumentsStringList) && Objects.equals(characterSet, that.characterSet) - && useCharsetKeyword == that.useCharsetKeyword + && characterSetSyntax == that.characterSetSyntax && Objects.equals(intervalQualifier, that.intervalQualifier) && Objects.equals(arrayData, that.arrayData) && signedness == that.signedness @@ -465,7 +513,7 @@ public int hashCode() { .reduce(0, (hash, c) -> 31 * hash + c); result = 31 * result + Objects.hashCode(argumentsStringList); result = 31 * result + Objects.hashCode(characterSet); - result = 31 * result + Boolean.hashCode(useCharsetKeyword); + result = 31 * result + Objects.hashCode(characterSetSyntax); result = 31 * result + Objects.hashCode(intervalQualifier); result = 31 * result + Objects.hashCode(arrayData); result = 31 * result + Objects.hashCode(signedness); diff --git a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt index 1411612de..e30fa514f 100644 --- a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt +++ b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt @@ -15445,8 +15445,16 @@ ColDataType ColDataType(): ( LOOKAHEAD(1) typeModifier=MySqlTypeModifier() { colDataType.addTypeModifier(typeModifier); } )* [ LOOKAHEAD(2) ( LOOKAHEAD(2) "[" {tk=null;} [ tk= ] { array.add(tk!=null?Integer.valueOf(tk.image):null); } "]" )+ { colDataType.setArrayData(array); } ] - [ LOOKAHEAD({ (getToken(1).kind == K_CHARACTER && getToken(2).kind == K_SET) - || isKeywordAhead("CHARSET") }) TypeCharacterSet(colDataType) ] + [ + LOOKAHEAD({ (getToken(1).kind == K_CHARACTER && getToken(2).kind == K_SET) + || isKeywordAhead("CHARSET") }) TypeCharacterSet(colDataType) + | + LOOKAHEAD({ Dialect.MYSQL.name().equals(getAsString(Feature.dialect)) + && (isKeywordAhead("ASCII") || isKeywordAhead("UNICODE")) }) + tk= + { colDataType.setCharacterSetSyntax( + ColDataType.CharacterSetSyntax.valueOf(tk.image.toUpperCase(Locale.ROOT))); } + ] { requireDdlSyntax(colDataType.getXmlTypeModifier() == null || argumentsStringList.isEmpty(), diff --git a/src/test/java/net/sf/jsqlparser/statement/create/table/CharacterSetShorthandTest.java b/src/test/java/net/sf/jsqlparser/statement/create/table/CharacterSetShorthandTest.java new file mode 100644 index 000000000..e85042627 --- /dev/null +++ b/src/test/java/net/sf/jsqlparser/statement/create/table/CharacterSetShorthandTest.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.table; + +import net.sf.jsqlparser.JSQLParserException; +import net.sf.jsqlparser.expression.CastExpression; +import net.sf.jsqlparser.expression.JsonFunction; +import net.sf.jsqlparser.parser.AbstractJSqlParser.Dialect; +import net.sf.jsqlparser.parser.CCJSqlParserUtil; +import net.sf.jsqlparser.statement.create.table.ColDataType.CharacterSetSyntax; +import net.sf.jsqlparser.statement.select.PlainSelect; +import net.sf.jsqlparser.test.TestUtils; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +import static org.junit.jupiter.api.Assertions.*; + +class CharacterSetShorthandTest { + @ParameterizedTest + @CsvSource({"ASCII,latin1", "UNICODE,ucs2"}) + void shorthandsExposeCharacterSetAndRetainSpelling(String shorthand, String charset) + throws JSQLParserException { + PlainSelect select = (PlainSelect) TestUtils.assertSqlCanBeParsedAndDeparsed( + "SELECT CAST('ab' AS CHAR(10) " + shorthand + "), " + + "JSON_VALUE('{\"v\":\"ab\"}', '$.v' RETURNING CHAR(10) " + shorthand + + ")", + true, parser -> parser.withDialect(Dialect.MYSQL)); + ColDataType castType = + ((CastExpression) select.getSelectItem(0).getExpression()).getColDataType(); + ColDataType jsonType = + ((JsonFunction) select.getSelectItem(1).getExpression()).getReturningType(); + for (ColDataType type : new ColDataType[] {castType, jsonType}) { + assertEquals("CHAR", type.getBaseTypeName()); + assertEquals(charset, type.getCharacterSet()); + assertEquals(CharacterSetSyntax.valueOf(shorthand), type.getCharacterSetSyntax()); + } + TestUtils.assertSqlCanBeParsedAndDeparsed(select.toString(), false, + parser -> parser.withDialect(Dialect.MYSQL)); + TestUtils.assertSqlCanBeParsedAndDeparsed( + "CREATE TABLE t (j JSON, g CHAR(10) AS (JSON_VALUE(j, '$.v' RETURNING CHAR(10) " + + shorthand + ")) STORED)", + true, parser -> parser.withDialect(Dialect.MYSQL)); + CreateTable create = (CreateTable) TestUtils.assertSqlCanBeParsedAndDeparsed( + "CREATE TABLE t (c CHAR(10) " + shorthand + ")", true, + parser -> parser.withDialect(Dialect.MYSQL)); + assertEquals(charset, + create.getColumnDefinitions().get(0).getColDataType().getCharacterSet()); + } + + @Test + void parsedReturningTypeCanBeChanged() throws JSQLParserException { + String sql = "SELECT JSON_VALUE('{\"v\":\"ab\"}', '$.v' RETURNING CHAR(10) ASCII)"; + PlainSelect select = (PlainSelect) CCJSqlParserUtil.parse(sql, + parser -> parser.withDialect(Dialect.MYSQL)); + ColDataType type = + ((JsonFunction) select.getSelectItem(0).getExpression()).getReturningType(); + type.setCharacterSetSyntax(CharacterSetSyntax.UNICODE); + TestUtils.assertStatementCanBeDeparsedAs(select, sql.replace("ASCII", "UNICODE"), true); + type.setCharacterSet("utf8mb4"); + TestUtils.assertStatementCanBeDeparsedAs(select, + sql.replace("ASCII", "CHARACTER SET utf8mb4"), true); + TestUtils.assertSqlCanBeParsedAndDeparsed(select.toString(), false, + parser -> parser.withDialect(Dialect.MYSQL)); + } + + @Test + void characterSetSyntaxCanBeConstructedChangedAndCleared() throws JSQLParserException { + ColDataType type = new ColDataType("CHAR").addArgumentsStringList("10") + .withCharacterSetSyntax(CharacterSetSyntax.ASCII); + assertEquals("latin1", type.getCharacterSet()); + assertEquals("CHAR (10) ASCII", type.toString()); + type.setCharacterSetSyntax(CharacterSetSyntax.UNICODE); + assertEquals("ucs2", type.getCharacterSet()); + assertEquals("CHAR (10) UNICODE", type.toString()); + type.setCharacterSet("utf8mb4"); + assertEquals(CharacterSetSyntax.CHARACTER_SET, type.getCharacterSetSyntax()); + assertEquals("CHAR (10) CHARACTER SET utf8mb4", type.toString()); + type.setUseCharsetKeyword(true); + assertTrue(type.isUseCharsetKeyword()); + type.setCharacterSet("latin1"); + assertEquals("CHAR (10) CHARSET latin1", type.toString()); + TestUtils.assertSqlCanBeParsedAndDeparsed("SELECT CAST('ab' AS " + type + ")", true, + parser -> parser.withDialect(Dialect.MYSQL)); + type.setCharacterSetSyntax(CharacterSetSyntax.ASCII); + type.setUseCharsetKeyword(false); + assertEquals("CHAR (10) CHARACTER SET latin1", type.toString()); + type.setCharacterSetSyntax(CharacterSetSyntax.UNICODE); + type.setCharacterSet(null); + assertEquals("CHAR (10)", type.toString()); + } + + @Test + void syntaxParticipatesInEqualityAndHashCode() { + ColDataType ascii = + new ColDataType("CHAR").withCharacterSetSyntax(CharacterSetSyntax.ASCII); + ColDataType same = new ColDataType("char").withCharacterSetSyntax(CharacterSetSyntax.ASCII); + ColDataType explicit = new ColDataType("CHAR").withCharacterSet("latin1"); + assertEquals(ascii, same); + assertEquals(ascii.hashCode(), same.hashCode()); + assertNotEquals(ascii, explicit); + } + + @Test + void shorthandIsMySqlSpecificAndDoesNotReserveIdentifiers() throws JSQLParserException { + for (String shorthand : new String[] {"ASCII", "UNICODE"}) { + String sql = "SELECT CAST('ab' AS CHAR(10) " + shorthand + ")"; + assertThrows(JSQLParserException.class, () -> CCJSqlParserUtil.parse(sql)); + assertThrows(JSQLParserException.class, + () -> CCJSqlParserUtil.parse(sql, + parser -> parser.withDialect(Dialect.POSTGRESQL))); + TestUtils.assertSqlCanBeParsedAndDeparsed("SELECT " + shorthand + " FROM t", true, + parser -> parser.withDialect(Dialect.MYSQL)); + } + } +}