diff --git a/src/main/java/net/sf/jsqlparser/expression/JsonFunction.java b/src/main/java/net/sf/jsqlparser/expression/JsonFunction.java index dfad9451a..03a3e3da0 100644 --- a/src/main/java/net/sf/jsqlparser/expression/JsonFunction.java +++ b/src/main/java/net/sf/jsqlparser/expression/JsonFunction.java @@ -12,6 +12,7 @@ import java.util.ArrayList; import java.util.Locale; import java.util.Objects; +import java.util.function.Consumer; import net.sf.jsqlparser.parser.ASTNodeAccessImpl; import net.sf.jsqlparser.statement.create.table.ColDataType; @@ -77,6 +78,11 @@ public void setExpression(Expression expression) { } public StringBuilder append(StringBuilder builder) { + return appendTo(builder, builder::append); + } + + public StringBuilder appendTo(StringBuilder builder, + Consumer expressionPrinter) { switch (type) { case ERROR: builder.append("ERROR"); @@ -85,7 +91,12 @@ public StringBuilder append(StringBuilder builder) { builder.append("NULL"); break; case DEFAULT: - builder.append("DEFAULT ").append(expression); + builder.append("DEFAULT "); + if (expression == null) { + builder.append("null"); + } else { + expressionPrinter.accept(expression); + } break; case EMPTY: builder.append("EMPTY "); @@ -389,23 +400,28 @@ public T accept(ExpressionVisitor expressionVisitor, S context) { // avoid countless Builder --> String conversion public StringBuilder append(StringBuilder builder) { + return appendTo(builder, builder::append); + } + + /** Renders structured children through the supplied expression printer. */ + public StringBuilder appendTo(StringBuilder builder, Consumer expressionPrinter) { switch (functionType) { case OBJECT: case POSTGRES_OBJECT: case MYSQL_OBJECT: - appendObject(builder); + appendObject(builder, expressionPrinter); break; case ARRAY: - appendArray(builder); + appendArray(builder, expressionPrinter); break; case VALUE: - appendValue(builder); + appendValue(builder, expressionPrinter); break; case QUERY: - appendQuery(builder); + appendQuery(builder, expressionPrinter); break; case EXISTS: - appendExists(builder); + appendExists(builder, expressionPrinter); break; default: // this should never happen really @@ -413,15 +429,20 @@ public StringBuilder append(StringBuilder builder) { return builder; } - @SuppressWarnings({"PMD.CyclomaticComplexity", "PMD.NPathComplexity"}) public StringBuilder appendObject(StringBuilder builder) { + return appendObject(builder, builder::append); + } + + @SuppressWarnings({"PMD.CyclomaticComplexity", "PMD.NPathComplexity"}) + public StringBuilder appendObject(StringBuilder builder, + Consumer expressionPrinter) { builder.append("JSON_OBJECT( "); int i = 0; for (JsonKeyValuePair keyValuePair : keyValuePairs) { if (i > 0) { builder.append(", "); } - keyValuePair.append(builder); + keyValuePair.appendTo(builder, expressionPrinter); i++; } @@ -467,8 +488,13 @@ private void appendUniqueKeys(StringBuilder builder) { } } - @SuppressWarnings({"PMD.CyclomaticComplexity", "PMD.NPathComplexity"}) public StringBuilder appendArray(StringBuilder builder) { + return appendArray(builder, builder::append); + } + + @SuppressWarnings({"PMD.CyclomaticComplexity", "PMD.NPathComplexity"}) + public StringBuilder appendArray(StringBuilder builder, + Consumer expressionPrinter) { builder.append("JSON_ARRAY( "); int i = 0; @@ -476,7 +502,7 @@ public StringBuilder appendArray(StringBuilder builder) { if (i > 0) { builder.append(", "); } - expr.append(builder); + expr.appendTo(builder, expressionPrinter); i++; } @@ -487,33 +513,43 @@ public StringBuilder appendArray(StringBuilder builder) { return builder; } - @SuppressWarnings({"PMD.CyclomaticComplexity", "PMD.NPathComplexity"}) public StringBuilder appendValue(StringBuilder builder) { + return appendValue(builder, builder::append); + } + + @SuppressWarnings({"PMD.CyclomaticComplexity", "PMD.NPathComplexity"}) + public StringBuilder appendValue(StringBuilder builder, + Consumer expressionPrinter) { builder.append("JSON_VALUE("); - appendValueOrQueryPrefix(builder); + appendValueOrQueryPrefix(builder, expressionPrinter); if (returningType != null) { builder.append(" RETURNING ").append(returningType); } - appendOnResponseClause(builder, onEmptyBehavior, "EMPTY"); - appendOnResponseClause(builder, onErrorBehavior, "ERROR"); + appendOnResponseClause(builder, onEmptyBehavior, "EMPTY", expressionPrinter); + appendOnResponseClause(builder, onErrorBehavior, "ERROR", expressionPrinter); builder.append(")"); return builder; } - @SuppressWarnings({"PMD.CyclomaticComplexity", "PMD.NPathComplexity"}) public StringBuilder appendQuery(StringBuilder builder) { + return appendQuery(builder, builder::append); + } + + @SuppressWarnings({"PMD.CyclomaticComplexity", "PMD.NPathComplexity"}) + public StringBuilder appendQuery(StringBuilder builder, + Consumer expressionPrinter) { builder.append("JSON_QUERY("); - appendValueOrQueryPrefix(builder); + appendValueOrQueryPrefix(builder, expressionPrinter); appendReturningClause(builder, true); appendWrapperClause(builder); appendQuotesClause(builder); - appendOnResponseClause(builder, onEmptyBehavior, "EMPTY"); - appendOnResponseClause(builder, onErrorBehavior, "ERROR"); + appendOnResponseClause(builder, onEmptyBehavior, "EMPTY", expressionPrinter); + appendOnResponseClause(builder, onErrorBehavior, "ERROR", expressionPrinter); for (String additionalQueryPathArgument : additionalQueryPathArguments) { builder.append(", ").append(additionalQueryPathArgument); @@ -523,25 +559,31 @@ public StringBuilder appendQuery(StringBuilder builder) { return builder; } - @SuppressWarnings({"PMD.CyclomaticComplexity", "PMD.NPathComplexity"}) public StringBuilder appendExists(StringBuilder builder) { + return appendExists(builder, builder::append); + } + + @SuppressWarnings({"PMD.CyclomaticComplexity", "PMD.NPathComplexity"}) + public StringBuilder appendExists(StringBuilder builder, + Consumer expressionPrinter) { builder.append("JSON_EXISTS("); - appendValueOrQueryPrefix(builder); - appendOnResponseClause(builder, onErrorBehavior, "ERROR"); + appendValueOrQueryPrefix(builder, expressionPrinter); + appendOnResponseClause(builder, onErrorBehavior, "ERROR", expressionPrinter); builder.append(")"); return builder; } - private void appendValueOrQueryPrefix(StringBuilder builder) { + private void appendValueOrQueryPrefix(StringBuilder builder, + Consumer expressionPrinter) { if (inputExpression != null) { - inputExpression.append(builder); + inputExpression.appendTo(builder, expressionPrinter); } if (jsonPathExpression != null) { if (inputExpression != null) { builder.append(", "); } - builder.append(jsonPathExpression); + expressionPrinter.accept(jsonPathExpression); } if (!passingExpressions.isEmpty()) { @@ -553,16 +595,20 @@ private void appendValueOrQueryPrefix(StringBuilder builder) { } else { comma = true; } - builder.append(passingExpression); + if (passingExpression == null) { + builder.append("null"); + } else { + expressionPrinter.accept(passingExpression); + } } } } private void appendOnResponseClause(StringBuilder builder, JsonOnResponseBehavior behavior, - String clause) { + String clause, Consumer expressionPrinter) { if (behavior != null) { builder.append(" "); - behavior.append(builder); + behavior.appendTo(builder, expressionPrinter); builder.append(" ON ").append(clause); } } diff --git a/src/main/java/net/sf/jsqlparser/expression/JsonFunctionExpression.java b/src/main/java/net/sf/jsqlparser/expression/JsonFunctionExpression.java index 738c09fc2..b8a7007f5 100644 --- a/src/main/java/net/sf/jsqlparser/expression/JsonFunctionExpression.java +++ b/src/main/java/net/sf/jsqlparser/expression/JsonFunctionExpression.java @@ -12,6 +12,7 @@ import java.io.Serializable; import java.util.Objects; +import java.util.function.Consumer; /** * @author Andreas Reichel @@ -58,7 +59,11 @@ public JsonFunctionExpression withEncoding(String encoding) { } public StringBuilder append(StringBuilder builder) { - builder.append(getExpression()); + return appendTo(builder, builder::append); + } + + public StringBuilder appendTo(StringBuilder builder, Consumer expressionPrinter) { + expressionPrinter.accept(getExpression()); if (isUsingFormatJson()) { builder.append(" FORMAT JSON"); if (encoding != null) { diff --git a/src/main/java/net/sf/jsqlparser/expression/JsonKeyValuePair.java b/src/main/java/net/sf/jsqlparser/expression/JsonKeyValuePair.java index 18fb4752d..bd0ed4dad 100644 --- a/src/main/java/net/sf/jsqlparser/expression/JsonKeyValuePair.java +++ b/src/main/java/net/sf/jsqlparser/expression/JsonKeyValuePair.java @@ -12,6 +12,7 @@ import java.io.Serializable; import java.util.Objects; +import java.util.function.Consumer; /** * @author Andreas Reichel @@ -153,14 +154,18 @@ public Object getValue() { } public StringBuilder append(StringBuilder builder) { + return appendTo(builder, builder::append); + } + + public StringBuilder appendTo(StringBuilder builder, Consumer expressionPrinter) { if (isUsingKeyKeyword() && getSeparator() == JsonKeyValuePairSeparator.VALUE) { builder.append("KEY "); } - builder.append(getKey()); + appendValue(builder, getKey(), expressionPrinter); if (getValue() != null) { builder.append(getSeparator().getSeparatorString()); - builder.append(getValue()); + appendValue(builder, getValue(), expressionPrinter); } if (isUsingFormatJson()) { @@ -173,6 +178,15 @@ public StringBuilder append(StringBuilder builder) { return builder; } + private static void appendValue(StringBuilder builder, Object value, + Consumer expressionPrinter) { + if (value instanceof Expression) { + expressionPrinter.accept((Expression) value); + } else { + builder.append(value); + } + } + @Override public String toString() { return append(new StringBuilder()).toString(); diff --git a/src/main/java/net/sf/jsqlparser/expression/JsonTableFunction.java b/src/main/java/net/sf/jsqlparser/expression/JsonTableFunction.java index ccaf584d2..65bb76f5f 100644 --- a/src/main/java/net/sf/jsqlparser/expression/JsonTableFunction.java +++ b/src/main/java/net/sf/jsqlparser/expression/JsonTableFunction.java @@ -13,6 +13,7 @@ import java.util.ArrayList; import java.util.List; import java.util.Objects; +import java.util.function.Consumer; import net.sf.jsqlparser.parser.ASTNodeAccessImpl; import net.sf.jsqlparser.statement.create.table.ColDataType; @@ -93,7 +94,14 @@ public void collectExpressions(List expressions) { @Override public String toString() { - return valueExpression + " AS " + parameterName; + StringBuilder builder = new StringBuilder(); + return appendTo(builder, builder::append).toString(); + } + + public StringBuilder appendTo(StringBuilder builder, + Consumer expressionPrinter) { + printExpression(builder, valueExpression, expressionPrinter); + return builder.append(" AS ").append(parameterName); } } @@ -305,13 +313,22 @@ public void collectExpressions(List expressions) { @Override public String toString() { + StringBuilder builder = new StringBuilder(); + return appendTo(builder, builder::append).toString(); + } + + public StringBuilder appendTo(StringBuilder builder, + Consumer expressionPrinter) { if (nestedPlanExpression != null) { - return "(" + nestedPlanExpression + ")"; + builder.append("("); + nestedPlanExpression.appendTo(builder, expressionPrinter); + builder.append(")"); + } else if (name != null) { + builder.append(name); + } else if (expression != null) { + expressionPrinter.accept(expression); } - if (name != null) { - return name; - } - return expression != null ? expression.toString() : ""; + return builder; } } @@ -348,13 +365,28 @@ public void collectExpressions(List expressions) { @Override public String toString() { StringBuilder builder = new StringBuilder(); + return appendTo(builder, builder::append).toString(); + } + + public StringBuilder appendTo(StringBuilder builder, + Consumer expressionPrinter) { if (!terms.isEmpty()) { - builder.append(terms.get(0)); + appendTerm(builder, terms.get(0), expressionPrinter); } for (int i = 0; i < operators.size() && i + 1 < terms.size(); i++) { - builder.append(operators.get(i).getDisplay()).append(terms.get(i + 1)); + builder.append(operators.get(i).getDisplay()); + appendTerm(builder, terms.get(i + 1), expressionPrinter); + } + return builder; + } + + private static void appendTerm(StringBuilder builder, JsonTablePlanTerm term, + Consumer expressionPrinter) { + if (term == null) { + builder.append("null"); + } else { + term.appendTo(builder, expressionPrinter); } - return builder.toString(); } } @@ -388,18 +420,36 @@ public void collectExpressions(List expressions) { @Override public String toString() { - StringBuilder builder = new StringBuilder("PLAN"); + StringBuilder builder = new StringBuilder(); + return appendTo(builder, builder::append).toString(); + } + + public StringBuilder appendTo(StringBuilder builder, + Consumer expressionPrinter) { + builder.append("PLAN"); if (defaultPlan) { builder.append(" DEFAULT"); } - builder.append(" (").append(planExpression).append(")"); - return builder.toString(); + builder.append(" ("); + if (planExpression == null) { + builder.append("null"); + } else { + planExpression.appendTo(builder, expressionPrinter); + } + builder.append(")"); + return builder; } } public abstract static class JsonTableColumnDefinition extends ASTNodeAccessImpl implements Serializable { public abstract void collectExpressions(List expressions); + + /** Custom column definitions retain their existing SQL renderer by default. */ + public StringBuilder appendTo(StringBuilder builder, + Consumer expressionPrinter) { + return builder.append(this); + } } public static class JsonTableNestedColumnDefinition extends JsonTableColumnDefinition { @@ -457,16 +507,28 @@ public void collectExpressions(List expressions) { @Override public String toString() { - StringBuilder builder = new StringBuilder("NESTED"); + StringBuilder builder = new StringBuilder(); + return appendTo(builder, builder::append).toString(); + } + + public StringBuilder appendTo(StringBuilder builder, + Consumer expressionPrinter) { + builder.append("NESTED"); if (pathKeyword) { builder.append(" PATH"); } - builder.append(" ").append(pathExpression); + builder.append(" "); + printExpression(builder, pathExpression, expressionPrinter); if (pathName != null) { builder.append(" AS ").append(pathName); } - builder.append(" ").append(columnsClause); - return builder.toString(); + builder.append(" "); + if (columnsClause == null) { + builder.append("null"); + } else { + columnsClause.appendTo(builder, expressionPrinter); + } + return builder; } } @@ -626,11 +688,38 @@ public void collectExpressions(List expressions) { @Override public String toString() { - StringBuilder builder = new StringBuilder(columnName); + StringBuilder builder = new StringBuilder(); + return appendTo(builder, builder::append).toString(); + } + + public StringBuilder appendTo(StringBuilder builder, + Consumer expressionPrinter) { + builder.append(columnName); if (forOrdinality) { builder.append(" FOR ORDINALITY"); - return builder.toString(); + return builder; + } + appendTypeOptions(builder); + appendPathOptions(builder, expressionPrinter); + if (onEmptyBehavior != null && !onEmptyAfterOnError) { + builder.append(" "); + onEmptyBehavior.appendTo(builder, expressionPrinter); + builder.append(" ON EMPTY"); + } + if (onErrorBehavior != null) { + builder.append(" "); + onErrorBehavior.appendTo(builder, expressionPrinter); + builder.append(" ON ERROR"); } + if (onEmptyBehavior != null && onEmptyAfterOnError) { + builder.append(" "); + onEmptyBehavior.appendTo(builder, expressionPrinter); + builder.append(" ON EMPTY"); + } + return builder; + } + + private void appendTypeOptions(StringBuilder builder) { if (dataType != null) { builder.append(" ").append(dataType); } @@ -651,11 +740,16 @@ public String toString() { if (exists) { builder.append(" EXISTS"); } + } + + private void appendPathOptions(StringBuilder builder, + Consumer expressionPrinter) { if (wrapperClause != null && wrapperClause.isBeforePathExpression()) { builder.append(" ").append(wrapperClause); } if (pathExpression != null) { - builder.append(" PATH ").append(pathExpression); + builder.append(" PATH "); + expressionPrinter.accept(pathExpression); } if (wrapperClause != null && !wrapperClause.isBeforePathExpression()) { builder.append(" ").append(wrapperClause); @@ -663,16 +757,6 @@ public String toString() { if (quotesClause != null) { builder.append(" ").append(quotesClause); } - if (onEmptyBehavior != null && !onEmptyAfterOnError) { - builder.append(" ").append(onEmptyBehavior).append(" ON EMPTY"); - } - if (onErrorBehavior != null) { - builder.append(" ").append(onErrorBehavior).append(" ON ERROR"); - } - if (onEmptyBehavior != null && onEmptyAfterOnError) { - builder.append(" ").append(onEmptyBehavior).append(" ON EMPTY"); - } - return builder.toString(); } } @@ -699,17 +783,27 @@ public void collectExpressions(List expressions) { @Override public String toString() { - StringBuilder builder = new StringBuilder("COLUMNS ("); + StringBuilder builder = new StringBuilder(); + return appendTo(builder, builder::append).toString(); + } + + public StringBuilder appendTo(StringBuilder builder, + Consumer expressionPrinter) { + builder.append("COLUMNS ("); boolean first = true; for (JsonTableColumnDefinition columnDefinition : columnDefinitions) { if (!first) { builder.append(", "); } - builder.append(columnDefinition); + if (columnDefinition == null) { + builder.append("null"); + } else { + columnDefinition.appendTo(builder, expressionPrinter); + } first = false; } builder.append(")"); - return builder.toString(); + return builder; } } @@ -834,17 +928,57 @@ public T accept(ExpressionVisitor expressionVisitor, S context) { @Override public String toString() { - StringBuilder builder = new StringBuilder("JSON_TABLE("); - builder.append(jsonInputExpression); + StringBuilder builder = new StringBuilder(); + return appendTo(builder, builder::append).toString(); + } + + public StringBuilder appendTo(StringBuilder builder, Consumer expressionPrinter) { + builder.append("JSON_TABLE("); + appendInputAndPath(builder, expressionPrinter); + appendPassingClauses(builder, expressionPrinter); + if (onErrorClause != null && onErrorClause.isBeforeColumns()) { + builder.append(" ").append(onErrorClause); + } + if (parsingTypeClause != null) { + builder.append(" ").append(parsingTypeClause); + } + if (onEmptyClause != null) { + builder.append(" ").append(onEmptyClause); + } + builder.append(" "); + if (columnsClause == null) { + builder.append("null"); + } else { + columnsClause.appendTo(builder, expressionPrinter); + } + if (planClause != null) { + builder.append(" "); + planClause.appendTo(builder, expressionPrinter); + } + if (onErrorClause != null && !onErrorClause.isBeforeColumns()) { + builder.append(" ").append(onErrorClause); + } + builder.append(")"); + return builder; + } + + private void appendInputAndPath(StringBuilder builder, + Consumer expressionPrinter) { + printExpression(builder, jsonInputExpression, expressionPrinter); if (formatJson) { builder.append(" FORMAT JSON"); } if (jsonPathExpression != null) { - builder.append(", ").append(jsonPathExpression); + builder.append(", "); + expressionPrinter.accept(jsonPathExpression); } if (pathName != null) { builder.append(" AS ").append(pathName); } + } + + private void appendPassingClauses(StringBuilder builder, + Consumer expressionPrinter) { if (!passingClauses.isEmpty()) { builder.append(" PASSING "); boolean first = true; @@ -852,27 +986,23 @@ public String toString() { if (!first) { builder.append(", "); } - builder.append(passingClause); + if (passingClause == null) { + builder.append("null"); + } else { + passingClause.appendTo(builder, expressionPrinter); + } first = false; } } - if (onErrorClause != null && onErrorClause.isBeforeColumns()) { - builder.append(" ").append(onErrorClause); - } - if (parsingTypeClause != null) { - builder.append(" ").append(parsingTypeClause); - } - if (onEmptyClause != null) { - builder.append(" ").append(onEmptyClause); - } - builder.append(" ").append(columnsClause); - if (planClause != null) { - builder.append(" ").append(planClause); - } - if (onErrorClause != null && !onErrorClause.isBeforeColumns()) { - builder.append(" ").append(onErrorClause); + } + + private static void printExpression(StringBuilder builder, Expression expression, + Consumer expressionPrinter) { + if (expression == null) { + builder.append("null"); + } else { + expressionPrinter.accept(expression); } - builder.append(")"); - return builder.toString(); } + } 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 336e80e5d..f7f4b187e 100644 --- a/src/main/java/net/sf/jsqlparser/util/deparser/ExpressionDeParser.java +++ b/src/main/java/net/sf/jsqlparser/util/deparser/ExpressionDeParser.java @@ -1736,14 +1736,12 @@ public StringBuilder visit(JsonAggregateFunction expression, S context) { @Override public StringBuilder visit(JsonFunction expression, S context) { - expression.append(builder); - return builder; + return expression.appendTo(builder, child -> child.accept(this, context)); } @Override public StringBuilder visit(JsonTableFunction expression, S context) { - builder.append(expression); - return builder; + return expression.appendTo(builder, child -> child.accept(this, context)); } @Override diff --git a/src/site/sphinx/usage.rst b/src/site/sphinx/usage.rst index 218d6dcf7..180c5e13a 100644 --- a/src/site/sphinx/usage.rst +++ b/src/site/sphinx/usage.rst @@ -926,6 +926,49 @@ Statement lists and nested blocks share the separator policy used by semicolon settings. Custom deparsers receive each child statement and the ``IF/ELSE`` condition through the visitor API, with the supplied context. +Customize JSON expression output +-------------------------------- + +``ExpressionDeParser`` visits structured children of ``JsonFunction`` and +``JsonTableFunction`` while rendering them. A custom expression printer can +rewrite inputs, paths, PASSING values and DEFAULT expressions, including nested +JSON_TABLE columns, without changing the AST. JSON object keys and values are +visited when they are ``Expression`` objects; opaque objects and additional +query-path strings retain their existing rendering. + +.. code-block:: java + + Statement statement = CCJSqlParserUtil.parse( + "SELECT JSON_VALUE((SELECT doc FROM source_table), '$.a')"); + ExpressionDeParser expressions = new ExpressionDeParser() { + @Override + public StringBuilder visit(Column column, S context) { + return getBuilder().append("payload"); + } + }; + statement.accept(new StatementDeParser(expressions, new SelectDeParser(), + expressions.getBuilder()), null); + // SELECT JSON_VALUE((SELECT payload FROM source_table), '$.a') + +``StatementDeParser`` connects the select and expression printers, so subqueries +inside JSON expressions use the same printer. For expression-only use with +subqueries, connect an appropriate ``SelectDeParser`` explicitly. + +JSON nodes also expose ``appendTo(StringBuilder, Consumer)`` for +direct rendering. The consumer prints each child into the same builder; +``ExpressionDeParser`` forwards the visitor context through that callback. +``JsonFunction`` additionally has consumer overloads of ``appendObject``, +``appendArray``, ``appendValue``, ``appendQuery`` and ``appendExists``. + +Subclass migration: override these consumer overloads or ``appendTo`` to +customize both default and visitor output. The deparser enters through ``appendTo``; +overriding only a legacy single-argument ``append*`` or ``toString()`` method does +not customize that path. ``JsonFunction`` also uses the consumer overloads for +type-specific default rendering. Subclasses of concrete JSON_TABLE column +definitions should move ``toString()`` customization +to ``appendTo``. Direct subclasses of the abstract ``JsonTableColumnDefinition`` +retain their opaque ``toString()`` fallback until they override ``appendTo``. + ODBC timestamp intervals ============================== diff --git a/src/test/java/net/sf/jsqlparser/util/deparser/JsonExpressionRenderingTest.java b/src/test/java/net/sf/jsqlparser/util/deparser/JsonExpressionRenderingTest.java new file mode 100644 index 000000000..d824f6b86 --- /dev/null +++ b/src/test/java/net/sf/jsqlparser/util/deparser/JsonExpressionRenderingTest.java @@ -0,0 +1,298 @@ +/*- + * #%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.util.deparser; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.ArrayList; +import java.util.List; +import java.util.function.Consumer; +import net.sf.jsqlparser.expression.Expression; +import net.sf.jsqlparser.expression.JsonFunction; +import net.sf.jsqlparser.expression.JsonFunctionExpression; +import net.sf.jsqlparser.expression.JsonFunctionType; +import net.sf.jsqlparser.expression.JsonKeyValuePair; +import net.sf.jsqlparser.expression.JsonKeyValuePairSeparator; +import net.sf.jsqlparser.expression.JsonTableFunction; +import net.sf.jsqlparser.expression.StringValue; +import net.sf.jsqlparser.parser.AbstractJSqlParser.Dialect; +import net.sf.jsqlparser.parser.CCJSqlParserUtil; +import net.sf.jsqlparser.schema.Column; +import net.sf.jsqlparser.statement.Statement; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +class JsonExpressionRenderingTest { + private static final Object CONTEXT = new Object(); + + @ParameterizedTest + @ValueSource(strings = { + "JSON_VALUE(doc, '$.a' RETURNING CHAR(20) DEFAULT 'old' ON EMPTY DEFAULT 'old' ON ERROR)", + "JSON_QUERY(doc, '$.a' PASSING filter_value RETURNING VARCHAR(20) " + + "FORMAT JSON WITH ARRAY WRAPPER EMPTY ARRAY ON EMPTY ERROR ON ERROR)", + "JSON_EXISTS(doc, '$.a' PASSING filter_value UNKNOWN ON ERROR)", + "JSON_ARRAY(doc, JSON_VALUE(other_doc, '$.a') FORMAT JSON)", + "JSON_OBJECT(KEY key_column VALUE JSON_VALUE(doc, '$.a') FORMAT JSON)" + }) + void rewritesScalarChildrenWithoutMutatingTheAst(String sql) throws Exception { + Expression expression = CCJSqlParserUtil.parseExpression(sql); + String before = expression.toString(); + RecordingDeParser printer = new RecordingDeParser(); + expression.accept(printer, CONTEXT); + String output = printer.getBuilder().toString(); + assertEquals(before.replace("key_column", "r_key_column") + .replace("other_doc", "other_payload").replace("doc", "payload") + .replace("filter_value", "r_filter_value").replace("'$.a'", "'$.b'") + .replace("'old'", "'new'"), output); + assertEquals(output, CCJSqlParserUtil.parseExpression(output).toString()); + assertEquals(before, expression.toString()); + assertTrue(printer.columns.contains("doc")); + assertTrue(printer.strings.contains("$.a")); + } + + @Test + void rewritesMySqlGeneratedColumnExpressions() throws Exception { + String sql = "CREATE TABLE t (doc JSON, payload JSON, value VARCHAR(20) AS " + + "(JSON_VALUE(doc, '$.a' RETURNING CHAR(20) DEFAULT 'old' ON EMPTY)) STORED)"; + Statement statement = CCJSqlParserUtil.parse(sql, p -> p.withDialect(Dialect.MYSQL)); + String before = statement.toString(); + RecordingDeParser printer = new RecordingDeParser(null); + statement.accept(new StatementDeParser(printer, new SelectDeParser(), printer.getBuilder()), + null); + String output = printer.getBuilder().toString(); + assertEquals(before.replace("JSON_VALUE(doc", "JSON_VALUE(payload") + .replace("'$.a'", "'$.b'").replace("'old'", "'new'"), output); + assertEquals(List.of("doc"), printer.columns); + assertEquals(output, CCJSqlParserUtil.parse(output, + p -> p.withDialect(Dialect.MYSQL)).toString()); + assertEquals(before, statement.toString()); + } + + @Test + void rewritesNestedTablePathsDefaultsAndPassingValuesInSourceOrder() throws Exception { + String sql = "SELECT * FROM JSON_TABLE(doc, '$.a' AS root " + + "PASSING filter_value AS filter COLUMNS (" + + "ord FOR ORDINALITY, value VARCHAR(20) PATH '$.a' " + + "DEFAULT 'old' ON ERROR DEFAULT 'old' ON EMPTY, " + + "NESTED PATH '$.a' AS nested COLUMNS (present INT EXISTS PATH '$.a')) " + + "PLAN (root OUTER nested) EMPTY ON ERROR) AS jt"; + Statement statement = CCJSqlParserUtil.parse(sql); + String before = statement.toString(); + RecordingDeParser printer = new RecordingDeParser(); + statement.accept(new StatementDeParser(printer, new SelectDeParser(), printer.getBuilder()), + CONTEXT); + String output = printer.getBuilder().toString(); + assertEquals(before.replace("JSON_TABLE(doc", "JSON_TABLE(payload") + .replace("filter_value", "r_filter_value").replace("'$.a'", "'$.b'") + .replace("'old'", "'new'"), output); + assertEquals(List.of("doc", "filter_value"), printer.columns); + assertEquals(List.of("$.a", "$.a", "old", "old", "$.a", "$.a"), printer.strings); + assertEquals(output, CCJSqlParserUtil.parse(output).toString()); + assertEquals(before, statement.toString()); + } + + @Test + void passesMySqlJsonTableDefaultsThroughTheExpressionPrinter() throws Exception { + String sql = "SELECT * FROM JSON_TABLE(doc, '$.a' COLUMNS " + + "(value VARCHAR(20) PATH '$.a' DEFAULT 'old' ON EMPTY " + + "DEFAULT 'old' ON ERROR)) AS jt"; + Statement statement = CCJSqlParserUtil.parse(sql, p -> p.withDialect(Dialect.MYSQL)); + RecordingDeParser printer = new RecordingDeParser(); + statement.accept(new StatementDeParser(printer, new SelectDeParser(), printer.getBuilder()), + CONTEXT); + String expected = statement.toString().replace("JSON_TABLE(doc", "JSON_TABLE(payload") + .replace("'$.a'", "'$.b'").replace("'old'", "'new'"); + assertEquals(expected, printer.getBuilder().toString()); + assertEquals(expected, CCJSqlParserUtil.parse(expected, + p -> p.withDialect(Dialect.MYSQL)).toString()); + } + + @Test + void rendersExpressionPlanTermsWithoutRewritingPathNames() { + JsonTableFunction.JsonTablePlanExpression nested = + new JsonTableFunction.JsonTablePlanExpression() + .addTerm(new JsonTableFunction.JsonTablePlanTerm() + .setExpression(new Column("doc"))); + JsonTableFunction.JsonTablePlanExpression plan = + new JsonTableFunction.JsonTablePlanExpression() + .addTerm(new JsonTableFunction.JsonTablePlanTerm().setName("doc")) + .addOperator(JsonTableFunction.JsonTablePlanOperator.OUTER) + .addTerm(new JsonTableFunction.JsonTablePlanTerm() + .setNestedPlanExpression(nested)); + RecordingDeParser printer = new RecordingDeParser(); + plan.appendTo(printer.getBuilder(), expression -> expression.accept(printer, CONTEXT)); + assertEquals("doc OUTER (payload)", printer.getBuilder().toString()); + assertEquals(List.of("doc"), printer.columns); + assertEquals("doc OUTER (doc)", plan.toString()); + } + + @Test + void leavesOpaqueKeyValueObjectsAndAdditionalQueryPathsUnchanged() throws Exception { + JsonKeyValuePair pair = new JsonKeyValuePair("raw_key", "raw_value", false, + JsonKeyValuePairSeparator.COLON); + RecordingDeParser printer = new RecordingDeParser(); + pair.appendTo(printer.getBuilder(), expression -> expression.accept(printer, CONTEXT)); + assertEquals(pair.toString(), printer.getBuilder().toString()); + assertTrue(printer.columns.isEmpty()); + JsonFunction query = (JsonFunction) CCJSqlParserUtil.parseExpression( + "JSON_QUERY(doc, '$.a' WITH ARRAY WRAPPER, '$.a')"); + RecordingDeParser queryPrinter = new RecordingDeParser(); + query.accept(queryPrinter, CONTEXT); + assertEquals("JSON_QUERY(payload, '$.b' WITH ARRAY WRAPPER, '$.a')", + queryPrinter.getBuilder().toString()); + assertEquals(List.of("$.a"), queryPrinter.strings); + } + + @Test + void rewritesScalarSubqueriesThroughTheConnectedStatementDeparser() throws Exception { + Statement statement = CCJSqlParserUtil.parse( + "SELECT JSON_VALUE((SELECT doc FROM source_table), '$.a')"); + String before = statement.toString(); + RecordingDeParser printer = new RecordingDeParser(null); + statement.accept(new StatementDeParser(printer, new SelectDeParser(), printer.getBuilder()), + null); + + String expected = before.replace("SELECT doc", "SELECT payload") + .replace("'$.a'", "'$.b'"); + assertEquals(expected, printer.getBuilder().toString()); + assertEquals(expected, CCJSqlParserUtil.parse(expected).toString()); + assertEquals(List.of("doc"), printer.columns); + assertEquals(before, statement.toString()); + } + + @Test + void preservesNullPassingExpressionsInIncompleteScalarBuilders() throws Exception { + JsonFunction function = (JsonFunction) CCJSqlParserUtil.parseExpression( + "JSON_VALUE(doc, '$.a')"); + function.addPassingExpression(null); + function.addPassingExpression(new Column("filter_value")); + RecordingDeParser printer = new RecordingDeParser(); + function.accept(printer, CONTEXT); + + assertEquals("JSON_VALUE(doc, '$.a' PASSING null, filter_value)", function.toString()); + assertEquals("JSON_VALUE(payload, '$.b' PASSING null, r_filter_value)", + printer.getBuilder().toString()); + assertEquals(List.of("doc", "filter_value"), printer.columns); + } + + @Test + void preservesNullPassingClausesAndPlanTermsInIncompleteTableBuilders() { + JsonTableFunction function = new JsonTableFunction() + .setJsonInputExpression(new Column("doc")) + .addPassingClause(new JsonTableFunction.JsonTablePassingClause( + new Column("filter_value"), "filter")) + .setPlanClause(new JsonTableFunction.JsonTablePlanClause().setPlanExpression( + new JsonTableFunction.JsonTablePlanExpression().addTerm(null) + .addOperator(JsonTableFunction.JsonTablePlanOperator.OUTER) + .addTerm(null))); + function.getPassingClauses().add(0, null); + RecordingDeParser printer = new RecordingDeParser(); + function.accept(printer, CONTEXT); + + assertEquals("JSON_TABLE(doc PASSING null, filter_value AS filter null " + + "PLAN (null OUTER null))", function.toString()); + assertEquals("JSON_TABLE(payload PASSING null, r_filter_value AS filter null " + + "PLAN (null OUTER null))", printer.getBuilder().toString()); + assertEquals(List.of("doc", "filter_value"), printer.columns); + } + + @Test + void scalarRendererOverloadsCustomizeBothDefaultAndVisitorOutput() { + JsonFunction function = new JsonFunction(JsonFunctionType.VALUE) { + @Override + public StringBuilder appendValue(StringBuilder builder, + Consumer expressionPrinter) { + return super.appendValue(builder, expressionPrinter).append(" /* custom */"); + } + }; + function.setInputExpression(new JsonFunctionExpression(new Column("doc"))); + function.setJsonPathExpression(new StringValue("$.a")); + RecordingDeParser printer = new RecordingDeParser(); + function.accept(printer, CONTEXT); + + assertEquals("JSON_VALUE(doc, '$.a') /* custom */", function.toString()); + assertEquals("JSON_VALUE(payload, '$.b') /* custom */", printer.getBuilder().toString()); + assertEquals(List.of("doc"), printer.columns); + } + + @Test + void columnAppendHookAndOpaqueCustomColumnsWorkInBothRenderers() { + JsonTableFunction.JsonTableColumnDefinition opaque = + new JsonTableFunction.JsonTableColumnDefinition() { + @Override + public void collectExpressions(List expressions) {} + + @Override + public String toString() { + return "legacy FOR ORDINALITY"; + } + }; + JsonTableFunction.JsonTableValueColumnDefinition structured = + new JsonTableFunction.JsonTableValueColumnDefinition() { + @Override + public StringBuilder appendTo(StringBuilder builder, + Consumer expressionPrinter) { + return super.appendTo(builder, expressionPrinter).append(" /* custom */"); + } + }; + structured.setColumnName("value").setPathExpression(new StringValue("$.a")); + JsonTableFunction function = new JsonTableFunction() + .setJsonInputExpression(new Column("doc")) + .setColumnsClause(new JsonTableFunction.JsonTableColumnsClause() + .addColumnDefinition(opaque).addColumnDefinition(structured)); + RecordingDeParser printer = new RecordingDeParser(); + function.accept(printer, CONTEXT); + + assertEquals("JSON_TABLE(doc COLUMNS (legacy FOR ORDINALITY, " + + "value PATH '$.a' /* custom */))", function.toString()); + assertEquals("JSON_TABLE(payload COLUMNS (legacy FOR ORDINALITY, " + + "value PATH '$.b' /* custom */))", printer.getBuilder().toString()); + assertEquals(List.of("doc"), printer.columns); + assertEquals(List.of("$.a"), printer.strings); + } + + private static class RecordingDeParser extends ExpressionDeParser { + private final List columns = new ArrayList<>(); + private final List strings = new ArrayList<>(); + private final Object expectedContext; + + RecordingDeParser() { + this(CONTEXT); + } + + RecordingDeParser(Object expectedContext) { + this.expectedContext = expectedContext; + } + + @Override + public StringBuilder visit(Column column, S context) { + assertSame(expectedContext, context); + columns.add(column.getColumnName()); + String name = column.getColumnName(); + String replacement = "doc".equals(name) ? "payload" + : "other_doc".equals(name) ? "other_payload" : "r_" + name; + return super.visit(new Column(column.getTable(), replacement), context); + } + + @Override + public StringBuilder visit(StringValue value, S context) { + assertSame(expectedContext, context); + strings.add(value.getValue()); + if ("$.a".equals(value.getValue()) || "old".equals(value.getValue())) { + return super.visit(new StringValue("$.a".equals(value.getValue()) ? "$.b" : "new"), + context); + } + return super.visit(value, context); + } + } +}