From d463fc01b15c1de8f0cf8c9162dc909b600b2d56 Mon Sep 17 00:00:00 2001 From: minleejae Date: Mon, 21 Sep 2026 23:19:08 +0900 Subject: [PATCH] feat: model MySQL expression-first CONVERT syntax Signed-off-by: minleejae --- .../expression/TranscodingFunction.java | 60 ++++++----- .../util/deparser/ExpressionDeParser.java | 25 +---- .../net/sf/jsqlparser/parser/JSqlParserCC.jjt | 16 +-- .../expression/MySqlConvertTest.java | 99 +++++++++++++++++++ 4 files changed, 147 insertions(+), 53 deletions(-) create mode 100644 src/test/java/net/sf/jsqlparser/expression/MySqlConvertTest.java diff --git a/src/main/java/net/sf/jsqlparser/expression/TranscodingFunction.java b/src/main/java/net/sf/jsqlparser/expression/TranscodingFunction.java index 355e86305..a784518c5 100644 --- a/src/main/java/net/sf/jsqlparser/expression/TranscodingFunction.java +++ b/src/main/java/net/sf/jsqlparser/expression/TranscodingFunction.java @@ -14,10 +14,16 @@ import net.sf.jsqlparser.statement.create.table.ColDataType; import java.util.Objects; +import java.util.function.Consumer; public class TranscodingFunction extends ASTNodeAccessImpl implements Expression { private String keyword = "CONVERT"; - private boolean isTranscodeStyle = true; + + public enum Syntax { + USING, TYPE_FIRST, TYPE_LAST + } + + private Syntax syntax = Syntax.USING; private ColDataType colDataType; private Expression expression; private String transcodingName; @@ -39,7 +45,7 @@ public TranscodingFunction(String keyword, ColDataType colDataType, Expression e this.colDataType = colDataType; this.expression = expression; this.transcodingName = transcodingName; - this.isTranscodeStyle = false; + this.syntax = Syntax.TYPE_FIRST; } public TranscodingFunction(ColDataType colDataType, Expression expression, @@ -47,7 +53,7 @@ public TranscodingFunction(ColDataType colDataType, Expression expression, this.colDataType = colDataType; this.expression = expression; this.transcodingName = transcodingName; - this.isTranscodeStyle = false; + this.syntax = Syntax.TYPE_FIRST; } public TranscodingFunction() { @@ -100,11 +106,11 @@ public TranscodingFunction setColDataType(ColDataType colDataType) { } public boolean isTranscodeStyle() { - return isTranscodeStyle; + return syntax == Syntax.USING; } public TranscodingFunction setTranscodeStyle(boolean transcodeStyle) { - isTranscodeStyle = transcodeStyle; + syntax = transcodeStyle ? Syntax.USING : Syntax.TYPE_FIRST; return this; } @@ -112,27 +118,33 @@ public T accept(ExpressionVisitor expressionVisitor, S context) { return expressionVisitor.visit(this, context); } + public Syntax getSyntax() { + return syntax; + } + + public TranscodingFunction setSyntax(Syntax syntax) { + this.syntax = Objects.requireNonNull(syntax); + return this; + } + public StringBuilder appendTo(StringBuilder builder) { - if (isTranscodeStyle) { - return builder - .append(keyword) - .append("( ") - .append(expression) - .append(" USING ") - .append(transcodingName) - .append(" )"); - } else { - return builder - .append(keyword) - .append("( ") - .append(colDataType) - .append(", ") - .append(expression) - .append(transcodingName != null && !transcodingName.isEmpty() - ? ", " + transcodingName - : "") - .append(" )"); + return appendTo(builder, builder::append); + } + + public StringBuilder appendTo(StringBuilder builder, Consumer expressionRenderer) { + builder.append(keyword).append("( "); + if (syntax == Syntax.TYPE_FIRST) { + builder.append(colDataType).append(", "); + } + expressionRenderer.accept(expression); + if (syntax == Syntax.USING) { + builder.append(" USING ").append(transcodingName); + } else if (syntax == Syntax.TYPE_LAST) { + builder.append(", ").append(colDataType); + } else if (transcodingName != null && !transcodingName.isEmpty()) { + builder.append(", ").append(transcodingName); } + return builder.append(" )"); } @Override diff --git a/src/main/java/net/sf/jsqlparser/util/deparser/ExpressionDeParser.java b/src/main/java/net/sf/jsqlparser/util/deparser/ExpressionDeParser.java index 3ad46ae72..b1991a5b5 100644 --- a/src/main/java/net/sf/jsqlparser/util/deparser/ExpressionDeParser.java +++ b/src/main/java/net/sf/jsqlparser/util/deparser/ExpressionDeParser.java @@ -743,29 +743,8 @@ public StringBuilder visit(Select select, S context) { @Override public StringBuilder visit(TranscodingFunction transcodingFunction, S context) { - if (transcodingFunction.isTranscodeStyle()) { - builder.append(transcodingFunction.getKeyword()); - builder.append("( "); - transcodingFunction.getExpression().accept(this, context); - builder.append(" USING ") - .append(transcodingFunction.getTranscodingName()) - .append(" )"); - } else { - builder - .append(transcodingFunction.getKeyword()) - .append("( ") - .append(transcodingFunction.getColDataType()) - .append(", "); - transcodingFunction.getExpression().accept(this, context); - - String transCodingName = transcodingFunction.getTranscodingName(); - if (transCodingName != null && !transCodingName.isEmpty()) { - builder.append(", ").append(transCodingName); - } - builder.append(" )"); - } - - return builder; + return transcodingFunction.appendTo(builder, + expression -> expression.accept(this, context)); } public StringBuilder visit(TrimFunction trimFunction, S context) { diff --git a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt index 6fd8d0375..85423e344 100644 --- a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt +++ b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt @@ -19545,7 +19545,8 @@ TranscodingFunction TranscodingFunction() #TranscodingFunction : ( keywordToken= | keywordToken= | keywordToken= ) "(" ( - LOOKAHEAD(4) colDataType = ColDataType() + LOOKAHEAD(4, { !Dialect.MYSQL.name().equals(getAsString(Feature.dialect)) }) + colDataType = ColDataType() "," expression = Expression() [ "," style = { transcodingName = style.image; } ] @@ -19555,11 +19556,14 @@ TranscodingFunction TranscodingFunction() #TranscodingFunction : | ( expression = Expression() - transcodingName=IdentifierChain() - - { - transcodingFunction = new TranscodingFunction(expression, transcodingName); - } + ( + transcodingName=IdentifierChain() + { transcodingFunction = new TranscodingFunction(keywordToken.image, expression, transcodingName); } + | + "," colDataType=ColDataType() + { transcodingFunction = new TranscodingFunction(keywordToken.image, colDataType, expression, null) + .setSyntax(TranscodingFunction.Syntax.TYPE_LAST); } + ) ) ) ")" diff --git a/src/test/java/net/sf/jsqlparser/expression/MySqlConvertTest.java b/src/test/java/net/sf/jsqlparser/expression/MySqlConvertTest.java new file mode 100644 index 000000000..94c0c04dd --- /dev/null +++ b/src/test/java/net/sf/jsqlparser/expression/MySqlConvertTest.java @@ -0,0 +1,99 @@ +/*- + * #%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.expression; + +import static org.junit.jupiter.api.Assertions.*; +import net.sf.jsqlparser.JSQLParserException; +import net.sf.jsqlparser.parser.AbstractJSqlParser.Dialect; +import net.sf.jsqlparser.parser.CCJSqlParserUtil; +import net.sf.jsqlparser.schema.Column; +import net.sf.jsqlparser.statement.select.PlainSelect; +import net.sf.jsqlparser.util.deparser.ExpressionDeParser; +import net.sf.jsqlparser.util.deparser.SelectDeParser; +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 MySqlConvertTest { + @ParameterizedTest + @ValueSource(strings = {"'42', SIGNED", "'-1', UNSIGNED INTEGER", + "'3.14', DECIMAL(10,2)", "'xx', CHAR(16) CHARACTER SET 'utf8mb4'", + "'xx', CHAR(16) CHARSET binary", "'2026-09-21', DATE", + "NULL, CHAR", "amount, DECIMAL(12,4)", "COALESCE(amount, 0), SIGNED", + "CONVERT('42', SIGNED), CHAR(10)", "?, SIGNED"}) + void representsExpressionBeforeType(String arguments) throws Exception { + PlainSelect select = parse("SELECT CONVERT(" + arguments + ") FROM t", Dialect.MYSQL); + TranscodingFunction convert = conversion(select); + assertEquals(TranscodingFunction.Syntax.TYPE_LAST, convert.getSyntax()); + assertFalse(convert.isTranscodeStyle()); + assertNotNull(convert.getColDataType()); + roundTrip(select, Dialect.MYSQL); + } + + @Test + void distinguishesDialectDependentOrderAndLegacyAccessors() throws Exception { + TranscodingFunction mysql = conversion(parse("SELECT CONVERT(value, CHAR)", Dialect.MYSQL)); + assertInstanceOf(Column.class, mysql.getExpression()); + assertEquals("CHAR", mysql.getColDataType().getDataType()); + TranscodingFunction sqlserver = conversion( + parse("SELECT CONVERT(VARCHAR(10), value, 120)", Dialect.SQLSERVER)); + assertEquals(TranscodingFunction.Syntax.TYPE_FIRST, sqlserver.getSyntax()); + assertEquals("120", sqlserver.getTranscodingName()); + roundTrip(parse("SELECT TRY_CONVERT(INT, value)", Dialect.SQLSERVER), Dialect.SQLSERVER); + roundTrip(parse("SELECT CONVERT(VARCHAR(10), value, 120)", Dialect.SQLSERVER), + Dialect.SQLSERVER); + TranscodingFunction using = + conversion(parse("SELECT CONVERT(value USING utf8mb4)", Dialect.MYSQL)); + assertTrue(using.isTranscodeStyle()); + using.setTranscodeStyle(false); + assertEquals(TranscodingFunction.Syntax.TYPE_FIRST, using.getSyntax()); + using.setTranscodeStyle(true); + assertEquals(TranscodingFunction.Syntax.USING, using.getSyntax()); + } + + @Test + void visitsOperandAndRendersTypeMutations() throws Exception { + PlainSelect select = parse("SELECT CONVERT(42, SIGNED)", Dialect.MYSQL); + conversion(select).getColDataType().setDataType("UNSIGNED"); + StringBuilder out = new StringBuilder(); + ExpressionDeParser visitor = new ExpressionDeParser() { + @Override + public StringBuilder visit(LongValue value, S context) { + return getBuilder().append(value.getValue() + 1); + } + }; + select.accept(new StatementDeParser(visitor, new SelectDeParser(), out), null); + assertEquals("SELECT CONVERT( 43, UNSIGNED )", out.toString()); + roundTrip(select, Dialect.MYSQL); + } + + @ParameterizedTest + @ValueSource(strings = {"SELECT CONVERT(1,)", "SELECT CONVERT(, SIGNED)", + "SELECT CONVERT(1, SIGNED, 120)", "SELECT CONVERT(1 USING)"}) + void rejectsIncompleteArguments(String sql) { + assertThrows(JSQLParserException.class, () -> parse(sql, Dialect.MYSQL)); + } + + private static PlainSelect parse(String sql, Dialect dialect) throws JSQLParserException { + return (PlainSelect) CCJSqlParserUtil.parse(sql, p -> p.withDialect(dialect)); + } + + private static TranscodingFunction conversion(PlainSelect select) { + return assertInstanceOf(TranscodingFunction.class, select.getSelectItem(0).getExpression()); + } + + private static void roundTrip(PlainSelect select, Dialect dialect) throws Exception { + StringBuilder out = new StringBuilder(); + select.accept(new StatementDeParser(out), null); + assertEquals(select.toString(), out.toString()); + assertEquals(select.toString(), parse(out.toString(), dialect).toString()); + } +}