From 583a0b4a23a2566a5545be0c644636846ba73b71 Mon Sep 17 00:00:00 2001 From: Minjae Lee Date: Sun, 6 Sep 2026 23:36:31 +0900 Subject: [PATCH] Support Oracle XMLSERIALIZE CONTENT and DOCUMENT forms --- .../expression/ExpressionVisitorAdapter.java | 8 +- .../expression/XMLSerializeExpr.java | 150 ++++++++++++++++- .../sf/jsqlparser/util/TablesNamesFinder.java | 4 +- .../util/deparser/ExpressionDeParser.java | 17 +- .../util/deparser/OrderByDeParser.java | 30 +++- .../validator/ExpressionValidator.java | 4 + .../net/sf/jsqlparser/parser/JSqlParserCC.jjt | 47 ++++-- .../expression/XMLSerializeExprTest.java | 151 ++++++++++++++++++ 8 files changed, 365 insertions(+), 46 deletions(-) create mode 100644 src/test/java/net/sf/jsqlparser/expression/XMLSerializeExprTest.java diff --git a/src/main/java/net/sf/jsqlparser/expression/ExpressionVisitorAdapter.java b/src/main/java/net/sf/jsqlparser/expression/ExpressionVisitorAdapter.java index 853ffbfe9a..753a6791da 100644 --- a/src/main/java/net/sf/jsqlparser/expression/ExpressionVisitorAdapter.java +++ b/src/main/java/net/sf/jsqlparser/expression/ExpressionVisitorAdapter.java @@ -713,13 +713,7 @@ public T visit(VariableAssignment variableAssignment, S context) { @Override public T visit(XMLSerializeExpr xmlSerializeExpr, S context) { - ArrayList subExpressions = new ArrayList<>(); - - subExpressions.add(xmlSerializeExpr.getExpression()); - for (OrderByElement orderByElement : xmlSerializeExpr.getOrderByElements()) { - subExpressions.add(orderByElement.getExpression()); - } - return visitExpressions(xmlSerializeExpr, context, subExpressions); + return visitExpressions(xmlSerializeExpr, context, xmlSerializeExpr.getExpressions()); } @Override diff --git a/src/main/java/net/sf/jsqlparser/expression/XMLSerializeExpr.java b/src/main/java/net/sf/jsqlparser/expression/XMLSerializeExpr.java index d2534e62d2..62a4e703aa 100644 --- a/src/main/java/net/sf/jsqlparser/expression/XMLSerializeExpr.java +++ b/src/main/java/net/sf/jsqlparser/expression/XMLSerializeExpr.java @@ -9,7 +9,9 @@ */ package net.sf.jsqlparser.expression; +import java.util.ArrayList; import java.util.List; +import java.util.function.Consumer; import static java.util.stream.Collectors.joining; @@ -19,9 +21,19 @@ public class XMLSerializeExpr extends ASTNodeAccessImpl implements Expression { + public enum SerializationMode { + CONTENT, DOCUMENT + } + private Expression expression; private List orderByElements; private ColDataType dataType; + private SerializationMode serializationMode; + private StringValue encoding; + private StringValue version; + private Boolean indent; + private LongValue indentSize; + private Boolean showDefaults; @Override public T accept(ExpressionVisitor expressionVisitor, S context) { @@ -52,11 +64,141 @@ public void setDataType(ColDataType dataType) { this.dataType = dataType; } + /** Null retains the legacy XMLAGG(XMLTEXT(...)) form and its existing expression getter. */ + public SerializationMode getSerializationMode() { + return serializationMode; + } + + public void setSerializationMode(SerializationMode serializationMode) { + this.serializationMode = serializationMode; + } + + public StringValue getEncoding() { + return encoding; + } + + public void setEncoding(StringValue encoding) { + this.encoding = encoding; + } + + public StringValue getVersion() { + return version; + } + + public void setVersion(StringValue version) { + this.version = version; + } + + /** Null preserves omission, true is INDENT, and false is NO INDENT. */ + public Boolean getIndent() { + return indent; + } + + public void setIndent(Boolean indent) { + this.indent = indent; + } + + public LongValue getIndentSize() { + return indentSize; + } + + public void setIndentSize(LongValue indentSize) { + this.indentSize = indentSize; + } + + /** Null preserves omission, true is SHOW DEFAULTS, and false is HIDE DEFAULTS. */ + public Boolean getShowDefaults() { + return showDefaults; + } + + public void setShowDefaults(Boolean showDefaults) { + this.showDefaults = showDefaults; + } + + /** Shared child discovery for expression, table-name and validation visitors. */ + public List getExpressions() { + List result = new ArrayList<>(); + if (expression != null) { + result.add(expression); + } + if (orderByElements != null) { + for (OrderByElement orderBy : orderByElements) { + result.add(orderBy.getExpression()); + } + } + if (encoding != null) { + result.add(encoding); + } + if (version != null) { + result.add(version); + } + if (indentSize != null) { + result.add(indentSize); + } + return result; + } + + /** Render both forms without bypassing custom expression or ORDER BY deparsers. */ + public StringBuilder appendTo(StringBuilder sql, Consumer expressionWriter, + Consumer> orderByWriter) { + validateOptions(); + sql.append("xmlserialize("); + if (serializationMode == null) { + sql.append("xmlagg(xmltext("); + expressionWriter.accept(expression); + sql.append(")"); + if (orderByElements != null) { + orderByWriter.accept(orderByElements); + } + sql.append(") AS ").append(dataType); + } else { + sql.append(serializationMode).append(" "); + expressionWriter.accept(expression); + if (dataType != null) { + sql.append(" AS ").append(dataType); + } + if (encoding != null) { + sql.append(" ENCODING "); + expressionWriter.accept(encoding); + } + if (version != null) { + sql.append(" VERSION "); + expressionWriter.accept(version); + } + if (indent != null) { + sql.append(indent ? " INDENT" : " NO INDENT"); + if (indent && indentSize != null) { + sql.append(" SIZE = "); + expressionWriter.accept(indentSize); + } + } + if (showDefaults != null) { + sql.append(showDefaults ? " SHOW DEFAULTS" : " HIDE DEFAULTS"); + } + } + return sql.append(")"); + } + + public void validateOptions() { + if (indentSize != null && (!Boolean.TRUE.equals(indent) || indentSize.getValue() < 0)) { + throw new IllegalArgumentException( + "An indentation size requires INDENT and must be nonnegative"); + } + if (serializationMode == null && (encoding != null || version != null || indent != null + || indentSize != null || showDefaults != null)) { + throw new IllegalArgumentException("Serialization options require CONTENT or DOCUMENT"); + } + if (serializationMode != null && orderByElements != null && !orderByElements.isEmpty()) { + throw new IllegalArgumentException( + "ORDER BY belongs inside the serialized XMLAGG expression"); + } + } + @Override public String toString() { - return "xmlserialize(xmlagg(xmltext(" + expression + ")" - + (orderByElements != null ? " ORDER BY " + orderByElements.stream() - .map(OrderByElement::toString).collect(joining(", ")) : "") - + ") AS " + dataType + ")"; + StringBuilder sql = new StringBuilder(); + return appendTo(sql, sql::append, orderBy -> sql.append(" ORDER BY ") + .append(orderBy.stream().map(OrderByElement::toString).collect(joining(", ")))) + .toString(); } } diff --git a/src/main/java/net/sf/jsqlparser/util/TablesNamesFinder.java b/src/main/java/net/sf/jsqlparser/util/TablesNamesFinder.java index 167c54e155..b6077436a1 100644 --- a/src/main/java/net/sf/jsqlparser/util/TablesNamesFinder.java +++ b/src/main/java/net/sf/jsqlparser/util/TablesNamesFinder.java @@ -2213,7 +2213,9 @@ public Void visit(VariableAssignment variableAssignment, S context) { @Override public Void visit(XMLSerializeExpr xmlSerializeExpr, S context) { - + for (Expression expression : xmlSerializeExpr.getExpressions()) { + expression.accept(this, context); + } return null; } 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 05f251019f..17ab206d65 100644 --- a/src/main/java/net/sf/jsqlparser/util/deparser/ExpressionDeParser.java +++ b/src/main/java/net/sf/jsqlparser/util/deparser/ExpressionDeParser.java @@ -1653,21 +1653,8 @@ public StringBuilder visit(VariableAssignment var, S context) { @Override public StringBuilder visit(XMLSerializeExpr expr, S context) { - // xmlserialize(xmlagg(xmltext(COMMENT_LINE) ORDER BY COMMENT_SEQUENCE) as varchar(1024)) - builder.append("xmlserialize(xmlagg(xmltext("); - expr.getExpression().accept(this, context); - builder.append(")"); - if (expr.getOrderByElements() != null) { - builder.append(" ORDER BY "); - for (Iterator i = expr.getOrderByElements().iterator(); i.hasNext();) { - builder.append(i.next().toString()); - if (i.hasNext()) { - builder.append(", "); - } - } - } - builder.append(") AS ").append(expr.getDataType()).append(")"); - return builder; + return expr.appendTo(builder, expression -> expression.accept(this, context), + orderBy -> new OrderByDeParser(this, builder).deParse(false, orderBy, context)); } @Override diff --git a/src/main/java/net/sf/jsqlparser/util/deparser/OrderByDeParser.java b/src/main/java/net/sf/jsqlparser/util/deparser/OrderByDeParser.java index 66e20bebb7..a45a49ed70 100644 --- a/src/main/java/net/sf/jsqlparser/util/deparser/OrderByDeParser.java +++ b/src/main/java/net/sf/jsqlparser/util/deparser/OrderByDeParser.java @@ -36,6 +36,11 @@ public void deParse(List orderByElementList) { } public void deParse(boolean oracleSiblings, List orderByElementList) { + deParse(oracleSiblings, orderByElementList, null); + } + + public void deParse(boolean oracleSiblings, List orderByElementList, + S context) { if (oracleSiblings) { builder.append(" ORDER SIBLINGS BY "); } else { @@ -45,7 +50,12 @@ public void deParse(boolean oracleSiblings, List orderByElementL for (Iterator iterator = orderByElementList.iterator(); iterator .hasNext();) { OrderByElement orderByElement = iterator.next(); - deParseElement(orderByElement); + if (context == null) { + // Preserve the customization point used by existing subclasses. + deParseElement(orderByElement); + } else { + deParseElement(orderByElement, context); + } if (iterator.hasNext()) { builder.append(", "); } @@ -53,7 +63,11 @@ public void deParse(boolean oracleSiblings, List orderByElementL } public void deParseElement(OrderByElement orderBy) { - orderBy.getExpression().accept(expressionVisitor, null); + deParseElement(orderBy, null); + } + + public void deParseElement(OrderByElement orderBy, S context) { + orderBy.getExpression().accept(expressionVisitor, context); if (!orderBy.isAsc()) { builder.append(" DESC"); } else if (orderBy.isAscDescPresent()) { @@ -67,30 +81,30 @@ public void deParseElement(OrderByElement orderBy) { } if (orderBy.getWithFill() != null) { builder.append(' '); - deParseWithFill(orderBy.getWithFill()); + deParseWithFill(orderBy.getWithFill(), context); } if (orderBy.isMysqlWithRollup()) { builder.append(" WITH ROLLUP"); } } - private void deParseWithFill(WithFill withFill) { + private void deParseWithFill(WithFill withFill, S context) { builder.append("WITH FILL"); if (withFill.getFrom() != null) { builder.append(" FROM "); - withFill.getFrom().accept(expressionVisitor, null); + withFill.getFrom().accept(expressionVisitor, context); } if (withFill.getTo() != null) { builder.append(" TO "); - withFill.getTo().accept(expressionVisitor, null); + withFill.getTo().accept(expressionVisitor, context); } if (withFill.getStep() != null) { builder.append(" STEP "); - withFill.getStep().accept(expressionVisitor, null); + withFill.getStep().accept(expressionVisitor, context); } if (withFill.getStaleness() != null) { builder.append(" STALENESS "); - withFill.getStaleness().accept(expressionVisitor, null); + withFill.getStaleness().accept(expressionVisitor, context); } } diff --git a/src/main/java/net/sf/jsqlparser/util/validation/validator/ExpressionValidator.java b/src/main/java/net/sf/jsqlparser/util/validation/validator/ExpressionValidator.java index 3f35986ee3..f6e4931775 100644 --- a/src/main/java/net/sf/jsqlparser/util/validation/validator/ExpressionValidator.java +++ b/src/main/java/net/sf/jsqlparser/util/validation/validator/ExpressionValidator.java @@ -1052,6 +1052,10 @@ public Void visit(TimezoneExpression a, S context) { @Override public Void visit(XMLSerializeExpr xml, S context) { + xml.validateOptions(); + for (Expression expression : xml.getExpressions()) { + expression.accept(this, context); + } return null; } diff --git a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt index ebb26f4929..027829f8ce 100644 --- a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt +++ b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt @@ -10751,26 +10751,51 @@ ExpressionList FunctionArgumentList(Function retval): } XMLSerializeExpr XMLSerializeExpr(): { - XMLSerializeExpr result; + XMLSerializeExpr result = new XMLSerializeExpr(); Expression expression; List orderByElements = null; ColDataType dataType; + Token token; } { - - "(" + "(" + ( + LOOKAHEAD({ isKeywordAhead("CONTENT") || isKeywordAhead("DOCUMENT") }) + token= + { result.setSerializationMode(XMLSerializeExpr.SerializationMode.valueOf(token.image.toUpperCase(Locale.ROOT))); } + expression=Expression() { result.setExpression(expression); } + [ dataType=ColDataType() { result.setDataType(dataType); } ] + [ token= { result.setEncoding(new StringValue(token.image)); } ] + [ token= { result.setVersion(new StringValue(token.image)); } ] + [ + LOOKAHEAD(2) token= { + requireDdlSyntax("INDENT".equalsIgnoreCase(token.image), "Expected INDENT after NO"); + result.setIndent(false); + } + | + LOOKAHEAD({ isKeywordAhead("INDENT") }) token= { result.setIndent(true); } + [ "=" token= { result.setIndentSize(new LongValue(token.image)); } ] + ] + [ + { result.setShowDefaults(true); } + | + LOOKAHEAD({ isKeywordAhead("HIDE") }) token= + { result.setShowDefaults(false); } + ] + | + "(" "(" expression=SimpleExpression() ")" [ orderByElements=OrderByElements() ] ")" - dataType=ColDataType() ")" - { - result = new XMLSerializeExpr(); - result.setExpression(expression); - result.setOrderByElements(orderByElements); - result.setDataType(dataType); - return result; - } + dataType=ColDataType() { + result.setExpression(expression); + result.setOrderByElements(orderByElements); + result.setDataType(dataType); + } + ) + ")" + { return result; } } diff --git a/src/test/java/net/sf/jsqlparser/expression/XMLSerializeExprTest.java b/src/test/java/net/sf/jsqlparser/expression/XMLSerializeExprTest.java new file mode 100644 index 0000000000..f3c6ea36c2 --- /dev/null +++ b/src/test/java/net/sf/jsqlparser/expression/XMLSerializeExprTest.java @@ -0,0 +1,151 @@ +/*- + * #%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 java.util.ArrayList; +import java.util.List; +import net.sf.jsqlparser.JSQLParserException; +import net.sf.jsqlparser.parser.CCJSqlParserUtil; +import net.sf.jsqlparser.schema.Column; +import net.sf.jsqlparser.statement.select.PlainSelect; +import net.sf.jsqlparser.statement.select.OrderByElement; +import net.sf.jsqlparser.test.TestUtils; +import net.sf.jsqlparser.util.TablesNamesFinder; +import net.sf.jsqlparser.util.deparser.ExpressionDeParser; +import net.sf.jsqlparser.util.deparser.OrderByDeParser; +import net.sf.jsqlparser.util.validation.validator.ExpressionValidator; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +class XMLSerializeExprTest { + @ParameterizedTest + @ValueSource(strings = { + "SELECT XMLSERIALIZE(CONTENT XMLTYPE('Grandco')) AS xmlserialize_doc FROM DUAL", + "SELECT XMLSERIALIZE(DOCUMENT payload AS CLOB) FROM docs", + "SELECT XMLSERIALIZE(CONTENT payload AS VARCHAR2(4000)) FROM docs", + "SELECT XMLSERIALIZE(CONTENT payload AS BLOB ENCODING 'UTF-8' VERSION '1.0' INDENT SIZE = 2 SHOW DEFAULTS) FROM docs", + "SELECT XMLSERIALIZE(DOCUMENT payload NO INDENT HIDE DEFAULTS) FROM docs", + "SELECT XMLSERIALIZE(CONTENT payload INDENT) FROM docs", + "SELECT XMLSERIALIZE(CONTENT payload INDENT SIZE = 0) FROM docs", + "SELECT XMLSERIALIZE(CONTENT XMLAGG(XMLELEMENT(NAME e, payload) ORDER BY id) AS CLOB) FROM docs", + "SELECT XMLSERIALIZE(CONTENT (SELECT payload FROM docs)) FROM DUAL", + "SELECT XMLSERIALIZE(CONTENT XMLTYPE('it''s XML') VERSION '1.1') FROM DUAL" + }) + void roundTripOracleSyntax(String sql) throws JSQLParserException { + TestUtils.assertSqlCanBeParsedAndDeparsed(sql); + } + + @ParameterizedTest + @ValueSource(strings = { + "XMLSERIALIZE(CONTENT)", + "XMLSERIALIZE(DOCUMENT x AS)", + "XMLSERIALIZE(CONTENT x NO INDENT SIZE = 2)", + "XMLSERIALIZE(CONTENT x INDENT SIZE = -1)", + "XMLSERIALIZE(CONTENT x ENCODING UTF8)", + "XMLSERIALIZE(CONTENT x VERSION 1)", + "XMLSERIALIZE(CONTENT x SHOW)", + "XMLSERIALIZE(CONTENT x NO SOMETHING)", + "XMLSERIALIZE(CONTENT x INDENT INDENT)" + }) + void rejectMalformedSyntax(String expression) { + assertThrows(JSQLParserException.class, () -> CCJSqlParserUtil.parseExpression(expression)); + } + + @Test + void inspectOptionsAndOmission() throws JSQLParserException { + XMLSerializeExpr xml = (XMLSerializeExpr) CCJSqlParserUtil.parseExpression( + "XMLSERIALIZE(CONTENT payload ENCODING 'UTF-8' VERSION '1.0' INDENT SIZE = 4 SHOW DEFAULTS)"); + assertEquals(XMLSerializeExpr.SerializationMode.CONTENT, xml.getSerializationMode()); + assertNull(xml.getDataType()); + assertEquals("UTF-8", xml.getEncoding().getValue()); + assertEquals("1.0", xml.getVersion().getValue()); + assertEquals(Boolean.TRUE, xml.getIndent()); + assertEquals(4, xml.getIndentSize().getValue()); + assertEquals(Boolean.TRUE, xml.getShowDefaults()); + assertEquals(4, xml.getExpressions().size()); + XMLSerializeExpr omitted = (XMLSerializeExpr) CCJSqlParserUtil.parseExpression( + "XMLSERIALIZE(DOCUMENT payload)"); + assertNull(omitted.getIndent()); + assertNull(omitted.getShowDefaults()); + omitted.setIndentSize(new LongValue(2)); + assertThrows(IllegalArgumentException.class, omitted::validateOptions); + } + + @Test + void legacyVisitorsHandleAbsentOrderBy() throws JSQLParserException { + XMLSerializeExpr xml = (XMLSerializeExpr) CCJSqlParserUtil.parseExpression( + "XMLSERIALIZE(XMLAGG(XMLTEXT(payload)) AS VARCHAR(100))"); + assertNull(xml.getSerializationMode()); + assertNull(xml.getOrderByElements()); + List seen = new ArrayList<>(); + xml.accept(new ExpressionVisitorAdapter() { + @Override + public Void visit(Column column, S context) { + seen.add(column.getColumnName() + ":" + context); + return null; + } + }, "context"); + assertEquals(List.of("payload:context"), seen); + } + + @Test + void visitAndRewriteLegacyOrderByWithContext() throws JSQLParserException { + XMLSerializeExpr xml = (XMLSerializeExpr) CCJSqlParserUtil.parseExpression( + "XMLSERIALIZE(XMLAGG(XMLTEXT(payload) ORDER BY id DESC NULLS LAST) AS VARCHAR(100))"); + StringBuilder sql = new StringBuilder(); + ExpressionDeParser writer = new ExpressionDeParser() { + @Override + public StringBuilder visit(Column column, S context) { + assertEquals("context", context); + return getBuilder().append("new_").append(column.getColumnName()); + } + }; + writer.setBuilder(sql); + xml.accept(writer, "context"); + assertEquals( + "xmlserialize(xmlagg(xmltext(new_payload) ORDER BY new_id DESC NULLS LAST) AS VARCHAR (100))", + sql.toString()); + List seen = new ArrayList<>(); + xml.accept(new ExpressionValidator() { + @Override + public Void visit(Column column, S context) { + seen.add(column.getColumnName()); + assertEquals("context", context); + return null; + } + }, "context"); + assertEquals(List.of("payload", "id"), seen); + } + + @Test + void discoverTablesInSerializedSubquery() throws JSQLParserException { + PlainSelect select = (PlainSelect) CCJSqlParserUtil.parse( + "SELECT XMLSERIALIZE(CONTENT (SELECT payload FROM docs)) FROM outer_source"); + assertTrue( + new TablesNamesFinder().getTableList((net.sf.jsqlparser.statement.Statement) select) + .containsAll(List.of("docs", "outer_source"))); + } + + @Test + void preserveLegacyOrderByCustomization() { + StringBuilder sql = new StringBuilder(); + OrderByDeParser writer = new OrderByDeParser(new ExpressionDeParser(), sql) { + @Override + public void deParseElement(OrderByElement element) { + getBuilder().append("custom_order"); + } + }; + writer.deParse(false, List.of(new OrderByElement().withExpression(new Column("id")))); + assertEquals(" ORDER BY custom_order", sql.toString()); + } +}