From 6752cc6b48853f5f6c076dad975b8a275037b7a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BB=98=E5=85=B8?= Date: Sun, 9 Aug 2026 03:01:00 +0800 Subject: [PATCH 1/2] feat: add support for the XMLTABLE table function MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit XMLTABLE (SQL/XML standard, supported by PostgreSQL and Oracle) turns an XML document into a relational row set. It is the XML counterpart of JSON_TABLE and shares its PASSING/COLUMNS structure, so it mirrors the existing JsonTableFunction without the JSON-specific clauses. Adds an XmlTableFunction AST node plus a grammar branch in the TableFunction dispatch covering: - a row XPath expression - an optional PASSING clause (value [AS name], ...) - an optional COLUMNS clause with FOR ORDINALITY, data type, PATH and DEFAULT per column Round-trips through parse/deparse and is wired into the expression visitor, deparser, table-names finder and validator. Fixes #2326 Signed-off-by: 付典 --- .../expression/ExpressionVisitor.java | 8 + .../expression/ExpressionVisitorAdapter.java | 5 + .../expression/XmlTableFunction.java | 235 ++++++++++++++++++ .../sf/jsqlparser/util/TablesNamesFinder.java | 19 +- .../util/deparser/ExpressionDeParser.java | 7 + .../validator/ExpressionValidator.java | 9 + .../net/sf/jsqlparser/parser/JSqlParserCC.jjt | 72 ++++++ .../jsqlparser/expression/XmlTableTest.java | 101 ++++++++ 8 files changed, 453 insertions(+), 3 deletions(-) create mode 100644 src/main/java/net/sf/jsqlparser/expression/XmlTableFunction.java create mode 100644 src/test/java/net/sf/jsqlparser/expression/XmlTableTest.java diff --git a/src/main/java/net/sf/jsqlparser/expression/ExpressionVisitor.java b/src/main/java/net/sf/jsqlparser/expression/ExpressionVisitor.java index 998c60ef5..08afe5129 100644 --- a/src/main/java/net/sf/jsqlparser/expression/ExpressionVisitor.java +++ b/src/main/java/net/sf/jsqlparser/expression/ExpressionVisitor.java @@ -659,6 +659,14 @@ default void visit(JsonTableFunction jsonTableFunction) { this.visit(jsonTableFunction, null); } + default T visit(XmlTableFunction xmlTableFunction, S context) { + return visit((Function) xmlTableFunction, context); + } + + default void visit(XmlTableFunction xmlTableFunction) { + this.visit(xmlTableFunction, null); + } + T visit(ConnectByRootOperator connectByRootOperator, S context); default void visit(ConnectByRootOperator connectByRootOperator) { diff --git a/src/main/java/net/sf/jsqlparser/expression/ExpressionVisitorAdapter.java b/src/main/java/net/sf/jsqlparser/expression/ExpressionVisitorAdapter.java index c546a4c2c..432c16354 100644 --- a/src/main/java/net/sf/jsqlparser/expression/ExpressionVisitorAdapter.java +++ b/src/main/java/net/sf/jsqlparser/expression/ExpressionVisitorAdapter.java @@ -758,6 +758,11 @@ public T visit(JsonTableFunction jsonTableFunction, S context) { return visitExpressions(jsonTableFunction, context, jsonTableFunction.getAllExpressions()); } + @Override + public T visit(XmlTableFunction xmlTableFunction, S context) { + return visitExpressions(xmlTableFunction, context, xmlTableFunction.getAllExpressions()); + } + @Override public T visit(ConnectByRootOperator connectByRootOperator, S context) { return connectByRootOperator.getColumn().accept(this, context); diff --git a/src/main/java/net/sf/jsqlparser/expression/XmlTableFunction.java b/src/main/java/net/sf/jsqlparser/expression/XmlTableFunction.java new file mode 100644 index 000000000..cdb6993b5 --- /dev/null +++ b/src/main/java/net/sf/jsqlparser/expression/XmlTableFunction.java @@ -0,0 +1,235 @@ +/*- + * #%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 java.io.Serializable; +import java.util.ArrayList; +import java.util.List; + +import net.sf.jsqlparser.parser.ASTNodeAccessImpl; +import net.sf.jsqlparser.statement.create.table.ColDataType; + +/** + * Represents an {@code XMLTABLE} table function (SQL/XML standard, supported by PostgreSQL and + * Oracle), which turns an XML document into a relational row set, e.g.: + * + *
+ * XMLTABLE('//ROWS/ROW'
+ *          PASSING data
+ *          COLUMNS id int PATH '@id',
+ *                  ordinality FOR ORDINALITY,
+ *                  country_id text PATH 'COUNTRY_ID',
+ *                  size float PATH 'SIZE' DEFAULT 0)
+ * 
+ * + * It is the XML counterpart of {@link JsonTableFunction} and mirrors its structure, without the + * JSON-specific wrapper/error/plan clauses. + */ +public class XmlTableFunction extends Function { + + private Expression rowPathExpression; + private final List passingClauses = new ArrayList<>(); + private final List columnDefinitions = new ArrayList<>(); + + public static class XmlTablePassingClause extends ASTNodeAccessImpl implements Serializable { + private Expression valueExpression; + private String name; + + public XmlTablePassingClause() {} + + public Expression getValueExpression() { + return valueExpression; + } + + public XmlTablePassingClause setValueExpression(Expression valueExpression) { + this.valueExpression = valueExpression; + return this; + } + + public String getName() { + return name; + } + + public XmlTablePassingClause setName(String name) { + this.name = name; + return this; + } + + public void collectExpressions(List expressions) { + if (valueExpression != null) { + expressions.add(valueExpression); + } + } + + @Override + public String toString() { + return valueExpression + (name != null ? " AS " + name : ""); + } + } + + public static class XmlTableColumnDefinition extends ASTNodeAccessImpl implements Serializable { + private String columnName; + private boolean forOrdinality; + private ColDataType dataType; + private Expression pathExpression; + private Expression defaultExpression; + + public XmlTableColumnDefinition() {} + + public String getColumnName() { + return columnName; + } + + public XmlTableColumnDefinition setColumnName(String columnName) { + this.columnName = columnName; + return this; + } + + public boolean isForOrdinality() { + return forOrdinality; + } + + public XmlTableColumnDefinition setForOrdinality(boolean forOrdinality) { + this.forOrdinality = forOrdinality; + return this; + } + + public ColDataType getDataType() { + return dataType; + } + + public XmlTableColumnDefinition setDataType(ColDataType dataType) { + this.dataType = dataType; + return this; + } + + public Expression getPathExpression() { + return pathExpression; + } + + public XmlTableColumnDefinition setPathExpression(Expression pathExpression) { + this.pathExpression = pathExpression; + return this; + } + + public Expression getDefaultExpression() { + return defaultExpression; + } + + public XmlTableColumnDefinition setDefaultExpression(Expression defaultExpression) { + this.defaultExpression = defaultExpression; + return this; + } + + public void collectExpressions(List expressions) { + if (pathExpression != null) { + expressions.add(pathExpression); + } + if (defaultExpression != null) { + expressions.add(defaultExpression); + } + } + + @Override + public String toString() { + StringBuilder builder = new StringBuilder(columnName); + if (forOrdinality) { + builder.append(" FOR ORDINALITY"); + return builder.toString(); + } + if (dataType != null) { + builder.append(" ").append(dataType); + } + if (pathExpression != null) { + builder.append(" PATH ").append(pathExpression); + } + if (defaultExpression != null) { + builder.append(" DEFAULT ").append(defaultExpression); + } + return builder.toString(); + } + } + + public Expression getRowPathExpression() { + return rowPathExpression; + } + + public XmlTableFunction setRowPathExpression(Expression rowPathExpression) { + this.rowPathExpression = rowPathExpression; + return this; + } + + public List getPassingClauses() { + return passingClauses; + } + + public XmlTableFunction addPassingClause(XmlTablePassingClause passingClause) { + passingClauses.add(passingClause); + return this; + } + + public List getColumnDefinitions() { + return columnDefinitions; + } + + public XmlTableFunction addColumnDefinition(XmlTableColumnDefinition columnDefinition) { + columnDefinitions.add(columnDefinition); + return this; + } + + public List getAllExpressions() { + List expressions = new ArrayList<>(); + if (rowPathExpression != null) { + expressions.add(rowPathExpression); + } + for (XmlTablePassingClause passingClause : passingClauses) { + passingClause.collectExpressions(expressions); + } + for (XmlTableColumnDefinition columnDefinition : columnDefinitions) { + columnDefinition.collectExpressions(expressions); + } + return expressions; + } + + @Override + public T accept(ExpressionVisitor expressionVisitor, S context) { + return expressionVisitor.visit(this, context); + } + + @Override + public String toString() { + StringBuilder builder = new StringBuilder("XMLTABLE("); + builder.append(rowPathExpression); + if (!passingClauses.isEmpty()) { + builder.append(" PASSING "); + boolean first = true; + for (XmlTablePassingClause passingClause : passingClauses) { + if (!first) { + builder.append(", "); + } + builder.append(passingClause); + first = false; + } + } + if (!columnDefinitions.isEmpty()) { + builder.append(" COLUMNS "); + boolean first = true; + for (XmlTableColumnDefinition columnDefinition : columnDefinitions) { + if (!first) { + builder.append(", "); + } + builder.append(columnDefinition); + first = false; + } + } + builder.append(")"); + return builder.toString(); + } +} diff --git a/src/main/java/net/sf/jsqlparser/util/TablesNamesFinder.java b/src/main/java/net/sf/jsqlparser/util/TablesNamesFinder.java index c1332e13c..6eea66a57 100644 --- a/src/main/java/net/sf/jsqlparser/util/TablesNamesFinder.java +++ b/src/main/java/net/sf/jsqlparser/util/TablesNamesFinder.java @@ -768,17 +768,20 @@ public Void visit(AnalyticExpression analytic, S context) { if (analytic.getWindowElement() != null) { if (analytic.getWindowElement().getRange() != null) { if (analytic.getWindowElement().getRange().getStart() != null - && analytic.getWindowElement().getRange().getStart().getExpression() != null) { + && analytic.getWindowElement().getRange().getStart() + .getExpression() != null) { analytic.getWindowElement().getRange().getStart().getExpression().accept(this, context); } if (analytic.getWindowElement().getRange().getEnd() != null - && analytic.getWindowElement().getRange().getEnd().getExpression() != null) { + && analytic.getWindowElement().getRange().getEnd() + .getExpression() != null) { analytic.getWindowElement().getRange().getEnd().getExpression().accept(this, context); } } - if (analytic.getWindowElement().getOffset() != null && analytic.getWindowElement().getOffset().getExpression() != null) { + if (analytic.getWindowElement().getOffset() != null + && analytic.getWindowElement().getOffset().getExpression() != null) { analytic.getWindowElement().getOffset().getExpression().accept(this, context); } @@ -1792,6 +1795,16 @@ public Void visit(JsonTableFunction expression, S context) { return null; } + @Override + public Void visit(XmlTableFunction expression, S context) { + for (Expression xmlExpression : expression.getAllExpressions()) { + if (xmlExpression != null) { + xmlExpression.accept(this, context); + } + } + return null; + } + @Override public Void visit(ConnectByRootOperator connectByRootOperator, S context) { connectByRootOperator.getColumn().accept(this, context); 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 397d902aa..184088fe7 100644 --- a/src/main/java/net/sf/jsqlparser/util/deparser/ExpressionDeParser.java +++ b/src/main/java/net/sf/jsqlparser/util/deparser/ExpressionDeParser.java @@ -45,6 +45,7 @@ import net.sf.jsqlparser.expression.JsonExpression; import net.sf.jsqlparser.expression.JsonFunction; import net.sf.jsqlparser.expression.JsonTableFunction; +import net.sf.jsqlparser.expression.XmlTableFunction; import net.sf.jsqlparser.expression.KeepExpression; import net.sf.jsqlparser.expression.KeyExpression; import net.sf.jsqlparser.expression.LambdaExpression; @@ -1659,6 +1660,12 @@ public StringBuilder visit(JsonTableFunction expression, S context) { return builder; } + @Override + public StringBuilder visit(XmlTableFunction expression, S context) { + builder.append(expression); + return builder; + } + @Override public StringBuilder visit(ConnectByRootOperator connectByRootOperator, S context) { builder.append("CONNECT_BY_ROOT "); 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 f017cc979..a18a21ab7 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 @@ -39,6 +39,7 @@ import net.sf.jsqlparser.expression.JsonExpression; import net.sf.jsqlparser.expression.JsonFunction; import net.sf.jsqlparser.expression.JsonTableFunction; +import net.sf.jsqlparser.expression.XmlTableFunction; import net.sf.jsqlparser.expression.KeepExpression; import net.sf.jsqlparser.expression.KeyExpression; import net.sf.jsqlparser.expression.LambdaExpression; @@ -1051,6 +1052,14 @@ public Void visit(JsonTableFunction expression, S context) { return null; } + @Override + public Void visit(XmlTableFunction expression, S context) { + for (Expression xmlExpression : expression.getAllExpressions()) { + validateOptionalExpression(xmlExpression, this); + } + return null; + } + @Override public Void visit(ConnectByRootOperator connectByRootOperator, S context) { connectByRootOperator.getColumn().accept(this, context); diff --git a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt index e1fc47ba3..f32ec1639 100644 --- a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt +++ b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt @@ -10192,6 +10192,71 @@ JsonTableFunction JsonTableBody() : { } } +XmlTableFunction.XmlTablePassingClause XmlTablePassingClause() : { + XmlTableFunction.XmlTablePassingClause passingClause = new XmlTableFunction.XmlTablePassingClause(); + Expression valueExpression; + String name = null; +} +{ + valueExpression = Expression() { passingClause.setValueExpression(valueExpression); } + [ name = RelObjectName() { passingClause.setName(name); } ] + { + return passingClause; + } +} + +XmlTableFunction.XmlTableColumnDefinition XmlTableColumnDefinition() : { + XmlTableFunction.XmlTableColumnDefinition columnDefinition = new XmlTableFunction.XmlTableColumnDefinition(); + String columnName; + ColDataType dataType; + Expression expression; +} +{ + columnName = RelObjectName() { columnDefinition.setColumnName(columnName); } + ( + { columnDefinition.setForOrdinality(true); } + | + dataType = ColDataType() { columnDefinition.setDataType(dataType); } + [ expression = Expression() { columnDefinition.setPathExpression(expression); } ] + [ expression = Expression() { columnDefinition.setDefaultExpression(expression); } ] + ) + { + return columnDefinition; + } +} + +XmlTableFunction XmlTableBody() : { + XmlTableFunction function = new XmlTableFunction(); + Expression rowPath; + XmlTableFunction.XmlTablePassingClause passingClause; + XmlTableFunction.XmlTableColumnDefinition columnDefinition; +} +{ + "(" + rowPath = Expression() { function.setRowPathExpression(rowPath); } + [ + LOOKAHEAD({ getToken(1).kind == S_IDENTIFIER && getToken(1).image.equalsIgnoreCase("PASSING") }) + + passingClause = XmlTablePassingClause() { function.addPassingClause(passingClause); } + ( + "," + passingClause = XmlTablePassingClause() { function.addPassingClause(passingClause); } + )* + ] + [ + + columnDefinition = XmlTableColumnDefinition() { function.addColumnDefinition(columnDefinition); } + ( + "," + columnDefinition = XmlTableColumnDefinition() { function.addColumnDefinition(columnDefinition); } + )* + ] + ")" + { + return function; + } +} + TableFunction TableFunction(): { Token prefix = null; @@ -10211,6 +10276,13 @@ TableFunction TableFunction(): JsonKeyword("JSON_TABLE") function = JsonTableBody() | + LOOKAHEAD({ + getToken(1).kind == S_IDENTIFIER + && getToken(1).image.equalsIgnoreCase("XMLTABLE") + }) + + function = XmlTableBody() + | function=Function() ) [ LOOKAHEAD(2) ( withClause = | withClause = ) ] diff --git a/src/test/java/net/sf/jsqlparser/expression/XmlTableTest.java b/src/test/java/net/sf/jsqlparser/expression/XmlTableTest.java new file mode 100644 index 000000000..f98cbe828 --- /dev/null +++ b/src/test/java/net/sf/jsqlparser/expression/XmlTableTest.java @@ -0,0 +1,101 @@ +/*- + * #%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 net.sf.jsqlparser.JSQLParserException; +import net.sf.jsqlparser.parser.CCJSqlParserUtil; +import net.sf.jsqlparser.statement.Statement; +import net.sf.jsqlparser.statement.create.table.ColDataType; +import net.sf.jsqlparser.statement.select.FromItem; +import net.sf.jsqlparser.statement.select.PlainSelect; +import net.sf.jsqlparser.statement.select.TableFunction; +import net.sf.jsqlparser.test.TestUtils; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import static org.assertj.core.api.Assertions.assertThat; + +public class XmlTableTest { + + @ParameterizedTest + @ValueSource(strings = { + // Issue #2326: full PostgreSQL XMLTABLE example + "SELECT x.* FROM xmldata, XMLTABLE('//ROWS/ROW' PASSING data COLUMNS\n" + + "id int PATH '@id',\n" + + "ordinality FOR ORDINALITY,\n" + + "\"COUNTRY_NAME\" text,\n" + + "country_id text PATH 'COUNTRY_ID',\n" + + "size_sq_km float PATH 'SIZE[@unit = \"sq_km\"]') x", + // Minimal forms + "SELECT * FROM XMLTABLE('//ROWS/ROW') x", + "SELECT * FROM XMLTABLE('//ROWS/ROW' COLUMNS id int) x", + "SELECT * FROM XMLTABLE('//ROWS/ROW' PASSING data) x", + "SELECT * FROM XMLTABLE('//ROWS/ROW' PASSING data COLUMNS id int) x", + // Passing alias, PATH and DEFAULT + "SELECT * FROM XMLTABLE('/a' PASSING data AS d COLUMNS c1 int PATH 'b' DEFAULT 0) x", + // DEFAULT without PATH + "SELECT * FROM XMLTABLE('/a' COLUMNS c1 int DEFAULT 5) x", + // Multiple passing arguments + "SELECT * FROM XMLTABLE('/a' PASSING data AS d, doc AS x COLUMNS c1 int) x" + }) + void testXmlTableRoundTrip(String sqlStr) throws JSQLParserException { + TestUtils.assertSqlCanBeParsedAndDeparsed(sqlStr, true); + } + + @Test + void testXmlTableStructure() throws JSQLParserException { + XmlTableFunction table = parseXmlTable( + "XMLTABLE('//ROWS/ROW' PASSING data AS d COLUMNS\n" + + "id int PATH '@id', ordinality FOR ORDINALITY, country text PATH 'COUNTRY_ID')"); + + assertThat(table.getRowPathExpression().toString()).isEqualTo("'//ROWS/ROW'"); + assertThat(table.getPassingClauses()).hasSize(1); + assertThat(table.getPassingClauses().get(0).getName()).isEqualTo("d"); + + assertThat(table.getColumnDefinitions()).hasSize(3); + + XmlTableFunction.XmlTableColumnDefinition ordinality = table.getColumnDefinitions().get(1); + assertThat(ordinality.getColumnName()).isEqualTo("ordinality"); + assertThat(ordinality.isForOrdinality()).isTrue(); + assertThat(ordinality.getDataType()).isNull(); + + XmlTableFunction.XmlTableColumnDefinition valueColumn = table.getColumnDefinitions().get(2); + assertThat(valueColumn.getColumnName()).isEqualTo("country"); + assertThat(valueColumn.isForOrdinality()).isFalse(); + assertThat(valueColumn.getDataType()).isInstanceOf(ColDataType.class); + assertThat(valueColumn.getDataType().getDataType()).isEqualTo("text"); + assertThat(valueColumn.getPathExpression().toString()).isEqualTo("'COUNTRY_ID'"); + } + + @Test + void testXmlTableDefaultExpression() throws JSQLParserException { + XmlTableFunction table = parseXmlTable( + "XMLTABLE('/a' COLUMNS c1 int PATH 'b' DEFAULT 0)"); + + XmlTableFunction.XmlTableColumnDefinition column = table.getColumnDefinitions().get(0); + assertThat(column.getPathExpression().toString()).isEqualTo("'b'"); + assertThat(column.getDefaultExpression().toString()).isEqualTo("0"); + } + + private XmlTableFunction parseXmlTable(String xmlTableStr) throws JSQLParserException { + String sql = "SELECT * FROM " + xmlTableStr; + Statement stmt = CCJSqlParserUtil.parse(sql); + + TestUtils.assertSqlCanBeParsedAndDeparsed(sql, true); + + FromItem fromItem = ((PlainSelect) stmt).getFromItem(); + assertThat(fromItem).isInstanceOf(TableFunction.class); + Function function = ((TableFunction) fromItem).getFunction(); + assertThat(function).isInstanceOf(XmlTableFunction.class); + + return (XmlTableFunction) function; + } +} From 4ee54d0b4e8d458884bb6dcd98d3c8f5a12c3e5e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BB=98=E5=85=B8?= Date: Sun, 9 Aug 2026 04:27:15 +0800 Subject: [PATCH 2/2] Drop redundant no-arg constructors flagged by Codacy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each inner class declared an empty public no-arg constructor as its only constructor, which is redundant — the compiler generates an equivalent default constructor (Codacy PMD UnnecessaryConstructor). Removed both; the grammar and tests that instantiate them with `new ...Clause()` are unaffected. Verified locally: full build + XmlTableTest pass on JDK 17; javap confirms the default constructor is still synthesized. Refs #2326 Signed-off-by: 付典 --- .../java/net/sf/jsqlparser/expression/XmlTableFunction.java | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/main/java/net/sf/jsqlparser/expression/XmlTableFunction.java b/src/main/java/net/sf/jsqlparser/expression/XmlTableFunction.java index cdb6993b5..9a4f4210b 100644 --- a/src/main/java/net/sf/jsqlparser/expression/XmlTableFunction.java +++ b/src/main/java/net/sf/jsqlparser/expression/XmlTableFunction.java @@ -42,8 +42,6 @@ public static class XmlTablePassingClause extends ASTNodeAccessImpl implements S private Expression valueExpression; private String name; - public XmlTablePassingClause() {} - public Expression getValueExpression() { return valueExpression; } @@ -81,8 +79,6 @@ public static class XmlTableColumnDefinition extends ASTNodeAccessImpl implement private Expression pathExpression; private Expression defaultExpression; - public XmlTableColumnDefinition() {} - public String getColumnName() { return columnName; }