From 27a865882ee37070ba1ad8f0128bc0894a7b973f Mon Sep 17 00:00:00 2001 From: minleejae Date: Thu, 12 Nov 2026 13:32:04 +0900 Subject: [PATCH] feat: support PostgreSQL SELECT INTO persistence, JOIN USING alias, AT LOCAL, IS NORMALIZED and XMLATTRIBUTES Read SELECT ... INTO [[LOCAL | GLOBAL] {TEMPORARY | TEMP} | UNLOGGED] [TABLE] table into a SelectIntoClause that also backs getIntoTables() and the trailing INTO TEMP table, the alias of JOIN ... USING (...) AS alias, expr AT LOCAL, expr IS [NOT] [NFC | NFD | NFKC | NFKD] NORMALIZED and XMLELEMENT(NAME name, XMLATTRIBUTES(value [AS name], ...), ...). AT TIME ZONE chains now associate from left to right, and the PostgreSQL dialect rejects SELECT INTO forms the server rejects. Signed-off-by: minleejae --- .../expression/ExpressionVisitor.java | 9 + .../expression/ExpressionVisitorAdapter.java | 6 + .../expression/TimezoneExpression.java | 36 +- .../relational/IsNormalizedExpression.java | 112 +++ .../statement/StatementFeatureVisitor.java | 5 +- .../sf/jsqlparser/statement/select/Join.java | 35 +- .../statement/select/PlainSelect.java | 75 +- .../statement/select/SelectIntoClause.java | 157 +++++ .../sf/jsqlparser/util/TablesNamesFinder.java | 7 + .../util/deparser/ExpressionDeParser.java | 15 +- .../util/deparser/SelectDeParser.java | 37 +- .../validation/feature/PostgresqlVersion.java | 3 +- .../validator/ExpressionValidator.java | 7 + .../validation/validator/SelectValidator.java | 3 +- .../net/sf/jsqlparser/parser/JSqlParserCC.jjt | 293 +++++++- .../select/PostgreSqlSelectFormsTest.java | 645 ++++++++++++++++++ .../resources/postgresql/select-forms.sql | 122 ++++ 17 files changed, 1480 insertions(+), 87 deletions(-) create mode 100644 src/main/java/net/sf/jsqlparser/expression/operators/relational/IsNormalizedExpression.java create mode 100644 src/main/java/net/sf/jsqlparser/statement/select/SelectIntoClause.java create mode 100644 src/test/java/net/sf/jsqlparser/statement/select/PostgreSqlSelectFormsTest.java create mode 100644 src/test/resources/postgresql/select-forms.sql diff --git a/src/main/java/net/sf/jsqlparser/expression/ExpressionVisitor.java b/src/main/java/net/sf/jsqlparser/expression/ExpressionVisitor.java index e272016245..4a6b4ef27e 100644 --- a/src/main/java/net/sf/jsqlparser/expression/ExpressionVisitor.java +++ b/src/main/java/net/sf/jsqlparser/expression/ExpressionVisitor.java @@ -45,6 +45,7 @@ import net.sf.jsqlparser.expression.operators.relational.Intersects; import net.sf.jsqlparser.expression.operators.relational.IsBooleanExpression; import net.sf.jsqlparser.expression.operators.relational.IsDistinctExpression; +import net.sf.jsqlparser.expression.operators.relational.IsNormalizedExpression; import net.sf.jsqlparser.expression.operators.relational.IsNullExpression; import net.sf.jsqlparser.expression.operators.relational.IsUnknownExpression; import net.sf.jsqlparser.expression.operators.relational.IsJsonExpression; @@ -395,6 +396,14 @@ default void visit(IsUnknownExpression isUnknownExpression) { this.visit(isUnknownExpression, null); } + default T visit(IsNormalizedExpression isNormalizedExpression, S context) { + return isNormalizedExpression.getLeftExpression().accept(this, context); + } + + default void visit(IsNormalizedExpression isNormalizedExpression) { + this.visit(isNormalizedExpression, null); + } + T visit(LikeExpression likeExpression, S context); default void visit(LikeExpression likeExpression) { diff --git a/src/main/java/net/sf/jsqlparser/expression/ExpressionVisitorAdapter.java b/src/main/java/net/sf/jsqlparser/expression/ExpressionVisitorAdapter.java index c5ed899855..11b052239f 100644 --- a/src/main/java/net/sf/jsqlparser/expression/ExpressionVisitorAdapter.java +++ b/src/main/java/net/sf/jsqlparser/expression/ExpressionVisitorAdapter.java @@ -48,6 +48,7 @@ import net.sf.jsqlparser.expression.operators.relational.Intersects; import net.sf.jsqlparser.expression.operators.relational.IsBooleanExpression; import net.sf.jsqlparser.expression.operators.relational.IsDistinctExpression; +import net.sf.jsqlparser.expression.operators.relational.IsNormalizedExpression; import net.sf.jsqlparser.expression.operators.relational.IsNullExpression; import net.sf.jsqlparser.expression.operators.relational.IsUnknownExpression; import net.sf.jsqlparser.expression.operators.relational.JsonOperator; @@ -296,6 +297,11 @@ public T visit(IsUnknownExpression isUnknownExpression, S context) { return isUnknownExpression.getLeftExpression().accept(this, context); } + @Override + public T visit(IsNormalizedExpression isNormalizedExpression, S context) { + return isNormalizedExpression.getLeftExpression().accept(this, context); + } + @Override public T visit(LikeExpression likeExpression, S context) { return visitExpressions(likeExpression, context, likeExpression.getLeftExpression(), diff --git a/src/main/java/net/sf/jsqlparser/expression/TimezoneExpression.java b/src/main/java/net/sf/jsqlparser/expression/TimezoneExpression.java index a67572acea..6959549879 100644 --- a/src/main/java/net/sf/jsqlparser/expression/TimezoneExpression.java +++ b/src/main/java/net/sf/jsqlparser/expression/TimezoneExpression.java @@ -14,11 +14,13 @@ import java.util.Arrays; import java.util.List; +import java.util.function.Consumer; public class TimezoneExpression extends ASTNodeAccessImpl implements Expression { private final ExpressionList timezoneExpressions = new ExpressionList<>(); private Expression leftExpression; + private boolean atLocal; public TimezoneExpression() { leftExpression = null; @@ -51,13 +53,37 @@ public void addTimezoneExpression(Expression... timezoneExpr) { this.timezoneExpressions.addAll(Arrays.asList(timezoneExpr)); } - @Override - public String toString() { - StringBuilder returnValue = new StringBuilder(leftExpression.toString()); + /** + * Whether the expression ends with {@code AT LOCAL}, which converts to the session time zone. + * It follows the {@code AT TIME ZONE} conversions of this expression, if there are any. + */ + public boolean isAtLocal() { + return atLocal; + } + + public TimezoneExpression setAtLocal(boolean atLocal) { + this.atLocal = atLocal; + return this; + } + + /** + * Appends the expression, rendering the operand and each time zone through the renderer. + */ + public StringBuilder appendTo(StringBuilder builder, Consumer expressionRenderer) { + expressionRenderer.accept(leftExpression); for (Expression expr : timezoneExpressions) { - returnValue.append(" AT TIME ZONE ").append(expr.toString()); + builder.append(" AT TIME ZONE "); + expressionRenderer.accept(expr); + } + if (atLocal) { + builder.append(" AT LOCAL"); } + return builder; + } - return returnValue.toString(); + @Override + public String toString() { + StringBuilder builder = new StringBuilder(); + return appendTo(builder, builder::append).toString(); } } diff --git a/src/main/java/net/sf/jsqlparser/expression/operators/relational/IsNormalizedExpression.java b/src/main/java/net/sf/jsqlparser/expression/operators/relational/IsNormalizedExpression.java new file mode 100644 index 0000000000..bd4593480f --- /dev/null +++ b/src/main/java/net/sf/jsqlparser/expression/operators/relational/IsNormalizedExpression.java @@ -0,0 +1,112 @@ +/*- + * #%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.operators.relational; + +import java.util.function.Consumer; +import net.sf.jsqlparser.expression.Expression; +import net.sf.jsqlparser.expression.ExpressionVisitor; +import net.sf.jsqlparser.parser.ASTNodeAccessImpl; + +/** + * The Unicode normalization test {@code expression IS [NOT] [NFC | NFD | NFKC | NFKD] NORMALIZED}. + */ +public class IsNormalizedExpression extends ASTNodeAccessImpl implements Expression { + /** The Unicode normalization forms. */ + public enum NormalForm { + NFC, NFD, NFKC, NFKD; + + /** Whether the word names a normalization form, in any case. */ + public static boolean isNormalForm(String word) { + for (NormalForm form : values()) { + if (form.name().equalsIgnoreCase(word)) { + return true; + } + } + return false; + } + } + + private Expression leftExpression; + private boolean not; + private NormalForm normalForm; + + public IsNormalizedExpression() { + // empty constructor + } + + public IsNormalizedExpression(Expression leftExpression) { + this.leftExpression = leftExpression; + } + + public Expression getLeftExpression() { + return leftExpression; + } + + public void setLeftExpression(Expression leftExpression) { + this.leftExpression = leftExpression; + } + + public IsNormalizedExpression withLeftExpression(Expression leftExpression) { + this.setLeftExpression(leftExpression); + return this; + } + + public E getLeftExpression(Class type) { + return type.cast(getLeftExpression()); + } + + public boolean isNot() { + return not; + } + + public void setNot(boolean not) { + this.not = not; + } + + public IsNormalizedExpression withNot(boolean not) { + this.setNot(not); + return this; + } + + /** The normalization form as written, or null when it is omitted (the server tests NFC). */ + public NormalForm getNormalForm() { + return normalForm; + } + + public void setNormalForm(NormalForm normalForm) { + this.normalForm = normalForm; + } + + public IsNormalizedExpression withNormalForm(NormalForm normalForm) { + this.setNormalForm(normalForm); + return this; + } + + @Override + public T accept(ExpressionVisitor expressionVisitor, S context) { + return expressionVisitor.visit(this, context); + } + + /** Appends the test, rendering the operand through the renderer. */ + public StringBuilder appendTo(StringBuilder builder, Consumer expressionRenderer) { + expressionRenderer.accept(leftExpression); + builder.append(not ? " IS NOT " : " IS "); + if (normalForm != null) { + builder.append(normalForm).append(' '); + } + return builder.append("NORMALIZED"); + } + + @Override + public String toString() { + StringBuilder builder = new StringBuilder(); + return appendTo(builder, builder::append).toString(); + } +} diff --git a/src/main/java/net/sf/jsqlparser/statement/StatementFeatureVisitor.java b/src/main/java/net/sf/jsqlparser/statement/StatementFeatureVisitor.java index be1c4cf274..f6b3f08903 100644 --- a/src/main/java/net/sf/jsqlparser/statement/StatementFeatureVisitor.java +++ b/src/main/java/net/sf/jsqlparser/statement/StatementFeatureVisitor.java @@ -1101,9 +1101,8 @@ public Void visit(PlainSelect plainSelect, S context) { // SELECT .. INTO materialises rows instead of returning them, so the // RETURNS_RESULT_SET claimed by the statement visit has to be withdrawn - boolean into = plainSelect.getIntoTables() != null - && !plainSelect.getIntoTables().isEmpty() - || plainSelect.getIntoTempTable() != null; + boolean into = plainSelect.getIntoClause() != null + && !plainSelect.getIntoClause().getTables().isEmpty(); if (into) { analysis.certain(StmtFeature.MODIFIES_DATA, StmtFeature.MODIFIES_SCHEMA); analysis.certain.remove(StmtFeature.RETURNS_RESULT_SET); diff --git a/src/main/java/net/sf/jsqlparser/statement/select/Join.java b/src/main/java/net/sf/jsqlparser/statement/select/Join.java index d06a5df1f8..04b7d0eb10 100644 --- a/src/main/java/net/sf/jsqlparser/statement/select/Join.java +++ b/src/main/java/net/sf/jsqlparser/statement/select/Join.java @@ -9,6 +9,7 @@ */ package net.sf.jsqlparser.statement.select; +import net.sf.jsqlparser.expression.Alias; import net.sf.jsqlparser.expression.Expression; import net.sf.jsqlparser.parser.ASTNodeAccessImpl; import net.sf.jsqlparser.schema.Column; @@ -25,6 +26,7 @@ public class Join extends ASTNodeAccessImpl { private final LinkedList onExpressions = new LinkedList<>(); private final LinkedList usingColumns = new LinkedList<>(); + private Alias usingAlias; private boolean asOf = false; private boolean outer = false; private boolean right = false; @@ -494,6 +496,23 @@ public Join withUsingColumns(List list) { return this; } + /** + * The alias of the USING columns, as in PostgreSQL's {@code a JOIN b USING (id) AS j}: a table + * alias that exposes only the columns listed in USING. Null when there is none. + */ + public Alias getUsingAlias() { + return usingAlias; + } + + public void setUsingAlias(Alias usingAlias) { + this.usingAlias = usingAlias; + } + + public Join withUsingAlias(Alias usingAlias) { + this.setUsingAlias(usingAlias); + return this; + } + public boolean isWindowJoin() { return joinWindow != null; } @@ -617,11 +636,23 @@ public String toString() { for (Expression onExpression : onExpressions) { builder.append(" ON ").append(onExpression); } + appendUsingTo(builder); + + return builder.toString(); + } + + /** + * Appends {@code USING (columns) [AS alias]} with a leading space, or nothing when the join has + * no USING columns. + */ + public StringBuilder appendUsingTo(StringBuilder builder) { if (!usingColumns.isEmpty()) { builder.append(PlainSelect.getFormattedList(usingColumns, "USING", true, true)); + if (usingAlias != null) { + builder.append(" AS ").append(usingAlias.getName()); + } } - - return builder.toString(); + return builder; } public Join addUsingColumns(Column... usingColumns) { diff --git a/src/main/java/net/sf/jsqlparser/statement/select/PlainSelect.java b/src/main/java/net/sf/jsqlparser/statement/select/PlainSelect.java index ea83b6d5ee..8cc87e7d83 100644 --- a/src/main/java/net/sf/jsqlparser/statement/select/PlainSelect.java +++ b/src/main/java/net/sf/jsqlparser/statement/select/PlainSelect.java @@ -16,7 +16,6 @@ import java.util.Collection; import java.util.Collections; import java.util.EnumSet; -import java.util.Iterator; import java.util.List; import java.util.Optional; import java.util.Set; @@ -35,7 +34,7 @@ public class PlainSelect extends Select { private Distinct distinct = null; private BigQuerySelectQualifier bigQuerySelectQualifier = null; private List> selectItems; - private List
intoTables; + private SelectIntoClause intoClause; private MySqlSelectIntoClause mySqlSelectIntoClause; private FromItem fromItem; private List lateralViews; @@ -69,7 +68,6 @@ public class PlainSelect extends Select { private boolean isUsingOnly = false; private boolean useWithNoLog = false; private boolean fromFirst = false; - private Table intoTempTable = null; private List settings = null; public enum EmitMode { @@ -143,12 +141,50 @@ public void setFromItem(FromItem item) { fromItem = item; } + /** + * The {@code INTO [persistence] [TABLE] table} clause, either after the select list or, for + * Informix's {@code INTO TEMP table}, at the end of the query. A SELECT has one such clause. + */ + public SelectIntoClause getIntoClause() { + return intoClause; + } + + public void setIntoClause(SelectIntoClause intoClause) { + this.intoClause = intoClause; + } + + public PlainSelect withIntoClause(SelectIntoClause intoClause) { + this.setIntoClause(intoClause); + return this; + } + + private boolean hasIntoClauseAt(SelectIntoClause.Position position) { + return intoClause != null && intoClause.getPosition() == position; + } + + /** + * The tables of the INTO clause after the select list, or null when there is none. This is a + * view of {@link #getIntoClause()}, which also holds the persistence and the TABLE keyword. + */ public List
getIntoTables() { - return intoTables; + return hasIntoClauseAt(SelectIntoClause.Position.BEFORE_FROM) ? intoClause.getTables() + : null; } + /** + * Sets the tables of the INTO clause after the select list, keeping the persistence and the + * TABLE keyword of such a clause. Null removes the clause. + */ public void setIntoTables(List
intoTables) { - this.intoTables = intoTables; + if (intoTables == null) { + if (hasIntoClauseAt(SelectIntoClause.Position.BEFORE_FROM)) { + intoClause = null; + } + } else if (hasIntoClauseAt(SelectIntoClause.Position.BEFORE_FROM)) { + intoClause.setTables(intoTables); + } else { + intoClause = new SelectIntoClause().withTables(intoTables); + } } public MySqlSelectIntoClause getMySqlSelectIntoClause() { @@ -328,12 +364,23 @@ public PlainSelect withUseWithNoLog(boolean useWithNoLog) { return this; } + /** + * The table of an {@code INTO TEMP table} clause at the end of the query, or null when there is + * none. This is a view of {@link #getIntoClause()}. + */ public Table getIntoTempTable() { - return intoTempTable; + return hasIntoClauseAt(SelectIntoClause.Position.TRAILING) ? intoClause.getTable() : null; } + /** Sets an {@code INTO TEMP table} clause at the end of the query. Null removes it. */ public void setIntoTempTable(Table intoTempTable) { - this.intoTempTable = intoTempTable; + if (intoTempTable != null) { + intoClause = new SelectIntoClause(intoTempTable) + .withPosition(SelectIntoClause.Position.TRAILING) + .withPersistence(SelectIntoClause.Persistence.TEMP); + } else if (hasIntoClauseAt(SelectIntoClause.Position.TRAILING)) { + intoClause = null; + } } public PlainSelect withIntoTempTable(Table intoTempTable) { @@ -618,8 +665,8 @@ public StringBuilder appendSelectBodyTo(StringBuilder builder) { builder.append(" QUALIFY ").append(qualify); } appendEmitClauseTo(builder); - if (intoTempTable != null) { - builder.append(" INTO TEMP ").append(intoTempTable); + if (hasIntoClauseAt(SelectIntoClause.Position.TRAILING)) { + builder.append(' ').append(intoClause); } if (useWithNoLog) { builder.append(" WITH NO LOG"); @@ -722,14 +769,8 @@ private void setMySqlSelectModifier(MySqlSelectModifier modifier, boolean enable } private void appendIntoClausesTo(StringBuilder builder) { - if (intoTables != null) { - builder.append(" INTO "); - for (Iterator
iter = intoTables.iterator(); iter.hasNext();) { - builder.append(iter.next().toString()); - if (iter.hasNext()) { - builder.append(", "); - } - } + if (hasIntoClauseAt(SelectIntoClause.Position.BEFORE_FROM)) { + builder.append(' ').append(intoClause); } if (mySqlSelectIntoClause != null diff --git a/src/main/java/net/sf/jsqlparser/statement/select/SelectIntoClause.java b/src/main/java/net/sf/jsqlparser/statement/select/SelectIntoClause.java new file mode 100644 index 0000000000..c7d713fba1 --- /dev/null +++ b/src/main/java/net/sf/jsqlparser/statement/select/SelectIntoClause.java @@ -0,0 +1,157 @@ +/*- + * #%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.select; + +import java.io.Serializable; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Objects; +import java.util.function.Consumer; +import net.sf.jsqlparser.parser.ASTNodeAccessImpl; +import net.sf.jsqlparser.schema.Table; + +/** + * The table target of a SELECT: {@code INTO [persistence] [TABLE] table}. + * + *

+ * PostgreSQL creates the table with the persistence written before its name, such as + * {@code INTO TEMP t}, {@code INTO LOCAL TEMPORARY TABLE t} or {@code INTO UNLOGGED t}. Other + * products write the plain {@code INTO t} after the select list as well, and Informix writes + * {@code INTO TEMP t} at the end of the query, which is the {@link Position#TRAILING} position. + */ +public class SelectIntoClause extends ASTNodeAccessImpl implements Serializable { + /** Where the clause is written in the SELECT. */ + public enum Position { + BEFORE_FROM, TRAILING + } + + /** The persistence keywords PostgreSQL accepts before the table name. */ + public enum Persistence { + TEMP, TEMPORARY, LOCAL_TEMP, LOCAL_TEMPORARY, GLOBAL_TEMP, GLOBAL_TEMPORARY, UNLOGGED; + + private final String keywords = name().replace('_', ' '); + + /** Reads the keywords as written, for example {@code "LOCAL TEMP"}. */ + public static Persistence from(String keywords) { + return valueOf(keywords.trim().toUpperCase(Locale.ROOT).replaceAll("\\s+", "_")); + } + + public String getKeywords() { + return keywords; + } + + public boolean isTemporary() { + return this != UNLOGGED; + } + } + + private Position position = Position.BEFORE_FROM; + private Persistence persistence; + private boolean tableKeyword; + private List

tables = new ArrayList<>(); + + public SelectIntoClause() { + // empty constructor + } + + public SelectIntoClause(Table table) { + tables.add(Objects.requireNonNull(table, "table")); + } + + public Position getPosition() { + return position; + } + + public void setPosition(Position position) { + this.position = Objects.requireNonNull(position, "position"); + } + + public SelectIntoClause withPosition(Position position) { + setPosition(position); + return this; + } + + /** The persistence written before the table name, or null when there is none. */ + public Persistence getPersistence() { + return persistence; + } + + public void setPersistence(Persistence persistence) { + this.persistence = persistence; + } + + public SelectIntoClause withPersistence(Persistence persistence) { + setPersistence(persistence); + return this; + } + + /** Whether the optional TABLE keyword precedes the table name. */ + public boolean isTableKeyword() { + return tableKeyword; + } + + public void setTableKeyword(boolean tableKeyword) { + this.tableKeyword = tableKeyword; + } + + public SelectIntoClause withTableKeyword(boolean tableKeyword) { + setTableKeyword(tableKeyword); + return this; + } + + /** The first target, which is the only one of a clause that creates a table. */ + public Table getTable() { + return tables.isEmpty() ? null : tables.get(0); + } + + /** The targets in the order written. The list is the clause's own and may be modified. */ + public List
getTables() { + return tables; + } + + public void setTables(List
tables) { + this.tables = Objects.requireNonNull(tables, "tables"); + } + + public SelectIntoClause withTables(List
tables) { + setTables(tables); + return this; + } + + public SelectIntoClause addTable(Table table) { + tables.add(Objects.requireNonNull(table, "table")); + return this; + } + + /** Appends the clause without a leading space, rendering each table through the renderer. */ + public StringBuilder appendTo(StringBuilder builder, Consumer
tableRenderer) { + builder.append("INTO "); + if (persistence != null) { + builder.append(persistence.getKeywords()).append(' '); + } + if (tableKeyword) { + builder.append("TABLE "); + } + for (int i = 0; i < tables.size(); i++) { + if (i > 0) { + builder.append(", "); + } + tableRenderer.accept(tables.get(i)); + } + return builder; + } + + @Override + public String toString() { + StringBuilder builder = new StringBuilder(); + return appendTo(builder, builder::append).toString(); + } +} diff --git a/src/main/java/net/sf/jsqlparser/util/TablesNamesFinder.java b/src/main/java/net/sf/jsqlparser/util/TablesNamesFinder.java index f65ddf14f6..363307ac79 100644 --- a/src/main/java/net/sf/jsqlparser/util/TablesNamesFinder.java +++ b/src/main/java/net/sf/jsqlparser/util/TablesNamesFinder.java @@ -55,6 +55,7 @@ import net.sf.jsqlparser.expression.operators.relational.IsBooleanExpression; import net.sf.jsqlparser.expression.operators.relational.IsDistinctExpression; import net.sf.jsqlparser.expression.operators.relational.IsJsonExpression; +import net.sf.jsqlparser.expression.operators.relational.IsNormalizedExpression; import net.sf.jsqlparser.expression.operators.relational.IsNullExpression; import net.sf.jsqlparser.expression.operators.relational.IsUnknownExpression; import net.sf.jsqlparser.expression.operators.relational.JsonOperator; @@ -871,6 +872,12 @@ public Void visit(IsUnknownExpression isUnknownExpression, S context) { return null; } + @Override + public Void visit(IsNormalizedExpression isNormalizedExpression, S context) { + isNormalizedExpression.getLeftExpression().accept(this, context); + return null; + } + @Override public Void visit(JdbcParameter jdbcParameter, S 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 99712d571b..677e38402b 100644 --- a/src/main/java/net/sf/jsqlparser/util/deparser/ExpressionDeParser.java +++ b/src/main/java/net/sf/jsqlparser/util/deparser/ExpressionDeParser.java @@ -121,6 +121,7 @@ import net.sf.jsqlparser.expression.operators.relational.IsBooleanExpression; import net.sf.jsqlparser.expression.operators.relational.IsDistinctExpression; import net.sf.jsqlparser.expression.operators.relational.IsJsonExpression; +import net.sf.jsqlparser.expression.operators.relational.IsNormalizedExpression; import net.sf.jsqlparser.expression.operators.relational.IsNullExpression; import net.sf.jsqlparser.expression.operators.relational.IsUnknownExpression; import net.sf.jsqlparser.expression.operators.relational.JsonOperator; @@ -608,6 +609,12 @@ public void visit(IsBooleanExpression isBooleanExpression) { visit(isBooleanExpression, null); } + @Override + public StringBuilder visit(IsNormalizedExpression isNormalizedExpression, S context) { + return isNormalizedExpression.appendTo(builder, + expression -> expression.accept(this, context)); + } + public void visit(IsUnknownExpression isUnknownExpression) { visit(isUnknownExpression, null); } @@ -1754,13 +1761,7 @@ public StringBuilder visit(XMLSerializeExpr expr, S context) { @Override public StringBuilder visit(TimezoneExpression var, S context) { - var.getLeftExpression().accept(this, context); - - for (Expression expr : var.getTimezoneExpressions()) { - builder.append(" AT TIME ZONE "); - expr.accept(this, context); - } - return builder; + return var.appendTo(builder, expression -> expression.accept(this, context)); } @Override diff --git a/src/main/java/net/sf/jsqlparser/util/deparser/SelectDeParser.java b/src/main/java/net/sf/jsqlparser/util/deparser/SelectDeParser.java index d844f3ec59..2e293b7d20 100644 --- a/src/main/java/net/sf/jsqlparser/util/deparser/SelectDeParser.java +++ b/src/main/java/net/sf/jsqlparser/util/deparser/SelectDeParser.java @@ -70,6 +70,7 @@ import net.sf.jsqlparser.statement.select.PlainSelect; import net.sf.jsqlparser.statement.select.SampleClause; import net.sf.jsqlparser.statement.select.Select; +import net.sf.jsqlparser.statement.select.SelectIntoClause; import net.sf.jsqlparser.statement.select.SelectItem; import net.sf.jsqlparser.statement.select.SelectItemVisitor; import net.sf.jsqlparser.statement.select.SelectVisitor; @@ -328,9 +329,7 @@ public StringBuilder visit(PlainSelect plainSelect, S context) { if (plainSelect.getForXmlPath() != null) { builder.append(" FOR XML PATH(").append(plainSelect.getForXmlPath()).append(")"); } - if (plainSelect.getIntoTempTable() != null) { - builder.append(" INTO TEMP ").append(plainSelect.getIntoTempTable()); - } + deparseIntoClause(plainSelect, SelectIntoClause.Position.TRAILING, context); if (plainSelect.isUseWithNoLog()) { builder.append(" WITH NO LOG"); } @@ -386,15 +385,7 @@ private void deparseSelectClause(PlainSelect plainSelect, S context) { builder.deleteCharAt(beforeItems - 1); } - if (plainSelect.getIntoTables() != null) { - builder.append(" INTO "); - for (Iterator
iter = plainSelect.getIntoTables().iterator(); iter.hasNext();) { - visit(iter.next(), context); - if (iter.hasNext()) { - builder.append(", "); - } - } - } + deparseIntoClause(plainSelect, SelectIntoClause.Position.BEFORE_FROM, context); deparseMySqlSelectInto(plainSelect, MySqlSelectIntoClause.Position.BEFORE_FROM, context); } @@ -756,6 +747,15 @@ private void deparseMySqlSelectInto(PlainSelect select, } } + private void deparseIntoClause(PlainSelect select, SelectIntoClause.Position position, + S context) { + SelectIntoClause into = select.getIntoClause(); + if (into != null && into.getPosition() == position) { + builder.append(' '); + into.appendTo(builder, table -> visit(table, context)); + } + } + @SuppressWarnings({"PMD.CyclomaticComplexity"}) public void deparseJoin(Join join) { if (join.isGlobal()) { @@ -790,18 +790,7 @@ public void deparseJoin(Join join) { builder.append(" ON "); onExpression.accept(expressionVisitor, null); } - if (!join.getUsingColumns().isEmpty()) { - builder.append(" USING ("); - for (Iterator iterator = join.getUsingColumns().iterator(); iterator - .hasNext();) { - Column column = iterator.next(); - builder.append(column.toString()); - if (iterator.hasNext()) { - builder.append(", "); - } - } - builder.append(")"); - } + join.appendUsingTo(builder); } diff --git a/src/main/java/net/sf/jsqlparser/util/validation/feature/PostgresqlVersion.java b/src/main/java/net/sf/jsqlparser/util/validation/feature/PostgresqlVersion.java index 23e3cc9a19..0870d3e9d7 100644 --- a/src/main/java/net/sf/jsqlparser/util/validation/feature/PostgresqlVersion.java +++ b/src/main/java/net/sf/jsqlparser/util/validation/feature/PostgresqlVersion.java @@ -30,7 +30,8 @@ public enum PostgresqlVersion implements Version { Feature.doStatement, Feature.exprLike, Feature.exprSimilarTo, // https://www.postgresql.org/docs/current/sql-select.html - Feature.select, + Feature.select, // https://www.postgresql.org/docs/current/sql-selectinto.html + Feature.selectInto, Feature.tableStatement, Feature.selectGroupBy, Feature.function, Feature.tableFunction, Feature.lateralSubSelect, 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 6b2d4927c6..53c3f4848a 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 @@ -117,6 +117,7 @@ import net.sf.jsqlparser.expression.operators.relational.Intersects; import net.sf.jsqlparser.expression.operators.relational.IsBooleanExpression; import net.sf.jsqlparser.expression.operators.relational.IsDistinctExpression; +import net.sf.jsqlparser.expression.operators.relational.IsNormalizedExpression; import net.sf.jsqlparser.expression.operators.relational.IsNullExpression; import net.sf.jsqlparser.expression.operators.relational.IsUnknownExpression; import net.sf.jsqlparser.expression.operators.relational.JsonOperator; @@ -323,6 +324,12 @@ public Void visit(IsUnknownExpression isUnknownExpression, S context) { return null; } + @Override + public Void visit(IsNormalizedExpression isNormalizedExpression, S context) { + isNormalizedExpression.getLeftExpression().accept(this, context); + return null; + } + @Override public Void visit(JdbcParameter jdbcParameter, S context) { validateFeature(Feature.jdbcParameter); diff --git a/src/main/java/net/sf/jsqlparser/util/validation/validator/SelectValidator.java b/src/main/java/net/sf/jsqlparser/util/validation/validator/SelectValidator.java index 1798e211f5..43cc70d749 100644 --- a/src/main/java/net/sf/jsqlparser/util/validation/validator/SelectValidator.java +++ b/src/main/java/net/sf/jsqlparser/util/validation/validator/SelectValidator.java @@ -115,8 +115,7 @@ public Void visit(PlainSelect plainSelect, S context) { || modifier == MySqlSelectModifier.SQL_BIG_RESULT || modifier == MySqlSelectModifier.SQL_BUFFER_RESULT), Feature.mysqlSelectModifiers); - validateOptionalFeature(c, plainSelect.getIntoTables(), Feature.selectInto); - validateOptionalFeature(c, plainSelect.getIntoTempTable(), Feature.selectInto); + validateOptionalFeature(c, plainSelect.getIntoClause(), Feature.selectInto); validateOptionalFeature(c, plainSelect.getMySqlSelectIntoClause(), Feature.selectInto); validateOptionalFeature(c, plainSelect.getKsqlWindow(), Feature.kSqlWindow); diff --git a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt index e4fcc2e76e..20fb54a8ff 100644 --- a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt +++ b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt @@ -226,6 +226,11 @@ public class CCJSqlParser extends AbstractJSqlParser { // depth of DuckDB MAP keys: the following ":" separates a key from its value // and must not be taken as the JSON path operator private int mapKeyDepth = 0; + /** + * Set while the zone of AT TIME ZONE is about to be read: the zone is a primary expression + * that leaves a following AT TIME ZONE or AT LOCAL to the expression it converts. + */ + private boolean timezoneOperandAhead = false; // opening brackets of the enclosing subscripts: a ":" at the top level of a subscript // separates the bounds of a slice such as l[1:3] and is not the JSON path operator @@ -338,6 +343,21 @@ public class CCJSqlParser extends AbstractJSqlParser { return list; } + /** + * Links the zones of a TimezoneExpression. One zone is its own range. Several zones span from + * the AT of the first one, since the text between two zones is not a list separator. + */ + private void linkTimezones(TimezoneExpression expression, Token firstAt) { + // the field behind this getter is an ExpressionList + ExpressionList zones = (ExpressionList) expression.getTimezoneExpressions(); + Node last = zones.size() > 1 ? zones.get(zones.size() - 1).getASTNode() : null; + if (last != null && firstAt != null && zones.getASTNode() == null) { + linkAST(zones, firstAt, last.jjtGetLastToken()); + } else { + linkElements(zones); + } + } + /** Links an object built from the single token just consumed, such as a name, to that token. */ private T linkToLastToken(T access) { linkAST(access, getToken(0), getToken(0)); @@ -3090,7 +3110,7 @@ public class CCJSqlParser extends AbstractJSqlParser { /** * True when an IS after a condition opens a postfix test - * (IS [NOT] TRUE/FALSE/UNKNOWN/NULL/JSON) rather than IS DISTINCT FROM or + * (IS [NOT] TRUE/FALSE/UNKNOWN/NULL/JSON/NORMALIZED) rather than IS DISTINCT FROM or * another comparison form, which has a right-hand operand of its own. */ private boolean isPostfixIsTestAhead() { @@ -3106,7 +3126,7 @@ public class CCJSqlParser extends AbstractJSqlParser { case K_JSON: return true; default: - return false; + return isIsNormalizedAhead(); } } @@ -3813,6 +3833,56 @@ public class CCJSqlParser extends AbstractJSqlParser { || "SQL_BUFFER_RESULT".equalsIgnoreCase(token.image)); } + /** Whether the dialect is PostgreSQL: forms the server rejects are then not read. */ + private boolean isPostgreSqlDialect() { + return Dialect.POSTGRESQL.name().equals(getAsString(Feature.dialect)); + } + + /** + * PostgreSQL's persistence and TABLE keywords of SELECT INTO are read with that dialect and + * without one, never with another dialect. + */ + private boolean isPostgreSqlSelectIntoDialect() { + return getAsString(Feature.dialect) == null || isPostgreSqlDialect(); + } + + /** + * Whether IS [NOT] [NFC | NFD | NFKC | NFKD] NORMALIZED follows. The form names and NORMALIZED + * are identifiers everywhere else. + */ + private boolean isIsNormalizedAhead() { + if (getToken(1).kind != K_IS) { + return false; + } + int offset = getToken(2).kind == K_NOT ? 3 : 2; + if (isWordAt(offset, "NORMALIZED")) { + return true; + } + Token form = getToken(offset); + return form.kind == S_IDENTIFIER && IsNormalizedExpression.NormalForm.isNormalForm(form.image) + && isWordAt(offset + 1, "NORMALIZED"); + } + + /** + * Whether the arguments of XMLELEMENT continue with XMLATTRIBUTES(...) after the element name, + * the only place where the attribute list with its AS names is allowed. PostgreSQL requires + * the NAME keyword before the element name. + */ + private boolean isXmlElementAttributesAhead(Function function) { + return hasUnqualifiedName(function, "XMLELEMENT") && ",".equals(getToken(2).image) + && isWordAt(3, "XMLATTRIBUTES") && getToken(4).kind == OPENING_BRACKET + && (!isPostgreSqlDialect() || "NAME".equalsIgnoreCase(function.getExtraKeyword())); + } + + private static boolean hasUnqualifiedColumns(List columns) { + for (Column column : columns) { + if (column.getTable() != null) { + return false; + } + } + return true; + } + private boolean isMySqlSelectIntoAhead() { if (getToken(1).kind != K_INTO) { return false; } int kind = getToken(2).kind; @@ -10540,7 +10610,7 @@ PlainSelect PlainSelect() #PlainSelect: PreferringClause preferringClause = null; ExpressionList expressionList = null; Token partitionFirst = null; - List
intoTables = null; + SelectIntoClause intoClause = null; MySqlSelectIntoClause mySqlSelectIntoClause = null; MySqlProcedureAnalyse mySqlProcedureAnalyse = null; Table updateTable = null; @@ -10551,7 +10621,6 @@ PlainSelect PlainSelect() #PlainSelect: boolean noWait = false; String windowName = null; WindowDefinition winDef; - Table intoTempTable = null; List settings = null; Distinct distinct; boolean all = false; @@ -10611,8 +10680,8 @@ PlainSelect PlainSelect() #PlainSelect: ( // PostgreSQL allows SELECT with no target expressions. - LOOKAHEAD({ Dialect.POSTGRESQL.name().equals(getAsString(Feature.dialect)) - && getToken(1).kind == K_FROM }) + LOOKAHEAD({ isPostgreSqlDialect() + && (getToken(1).kind == K_FROM || getToken(1).kind == K_INTO) }) { selectItems = new ArrayList>(); } | selectItems=SelectItemsList() @@ -10624,7 +10693,7 @@ PlainSelect PlainSelect() #PlainSelect: mySqlSelectIntoClause = MySqlSelectIntoClause(MySqlSelectIntoClause.Position.BEFORE_FROM) { plainSelect.setMySqlSelectIntoClause(mySqlSelectIntoClause); } | - intoTables = IntoClause() { plainSelect.setIntoTables(intoTables); } + intoClause = IntoClause() { plainSelect.setIntoClause(intoClause); } ) ] [ LOOKAHEAD(2) fromItem=FromItem() @@ -10719,7 +10788,7 @@ PlainSelect PlainSelect() #PlainSelect: ] [ LOOKAHEAD({ isMySqlSelectIntoAhead() }) { - if (mySqlSelectIntoClause != null || intoTables != null) { + if (mySqlSelectIntoClause != null || intoClause != null) { throw new ParseException("Only one INTO clause is allowed per SELECT"); } } @@ -10728,7 +10797,15 @@ PlainSelect PlainSelect() #PlainSelect: ] [ LOOKAHEAD(2) settings = UpdateSets() { plainSelect.setSettings(settings); } ] [ LOOKAHEAD(1, ) optimize = OptimizeFor() { plainSelect.setOptimizeFor(optimize); } ] - [ LOOKAHEAD(3) intoTempTable = Table() { plainSelect.setIntoTempTable(intoTempTable);} ] + // Informix writes INTO TEMP table at the end of the query, which PostgreSQL rejects + [ LOOKAHEAD( , { !isPostgreSqlDialect() }) + { + if (mySqlSelectIntoClause != null || intoClause != null) { + throw new ParseException("Only one INTO clause is allowed per SELECT"); + } + } + intoClause = TrailingIntoTempClause() { plainSelect.setIntoClause(intoClause); } + ] [ LOOKAHEAD(3) { plainSelect.setUseWithNoLog(true); } ] { plainSelect.setSelectItems(selectItems); @@ -11709,15 +11786,67 @@ UnPivot UnPivot(): } } -List
IntoClause(): +/** + * INTO after the select list. PostgreSQL (gram.y into_clause) creates one table and accepts a + * persistence and the TABLE keyword before its name: + * INTO [ [LOCAL | GLOBAL] {TEMPORARY | TEMP} | UNLOGGED ] [TABLE] table. + * A persistence word that no name follows is the table name itself, as in INTO temp FROM t. + * The list of targets is what other products and procedural languages write. + */ +SelectIntoClause IntoClause(): +{ + SelectIntoClause clause = new SelectIntoClause(); + Table table; + Token first; + Token scope = null; + Token persistence; +} +{ + first= + [ + LOOKAHEAD(( | [ | ] ( | ) ) + ( | RelObjectName() ), { isPostgreSqlSelectIntoDialect() }) + ( + { clause.setPersistence(SelectIntoClause.Persistence.UNLOGGED); } + | + [ scope= | scope= ] + ( persistence= | persistence= ) { + clause.setPersistence(SelectIntoClause.Persistence.from( + (scope == null ? "" : scope.image + " ") + persistence.image)); + } + ) + ] + [ + LOOKAHEAD( RelObjectName(), { isPostgreSqlSelectIntoDialect() }) + { clause.setTableKeyword(true); } + | + // TABLE is reserved in PostgreSQL, so that it never names the table there + LOOKAHEAD(, { isPostgreSqlDialect() }) + { clause.setTableKeyword(true); } + ] + table=Table() { clause.addTable(table); } + ( LOOKAHEAD(2) "," table=Table() { clause.addTable(table); } )* + { + requireSyntax(clause.getTables().size() == 1 || !isPostgreSqlDialect() + && clause.getPersistence() == null && !clause.isTableKeyword(), + "SELECT INTO creates a single table"); + linkAST(clause, first); + return clause; + } +} + +/** Informix: INTO TEMP table at the end of the query. */ +SelectIntoClause TrailingIntoTempClause(): { - List
tables = new ArrayList
(); Table table; + Token first; } { - table=Table() { tables.add(table); } ( LOOKAHEAD(2) "," table=Table() { tables.add(table); } )* + first= table=Table() { - return tables; + return linked(new SelectIntoClause(table) + .withPosition(SelectIntoClause.Position.TRAILING) + .withPersistence(SelectIntoClause.Persistence.TEMP), first); } } @@ -12338,6 +12467,7 @@ Join JoinerExpression() #JoinerExpression: KSQLJoinWindow joinWindow = null; JoinHint joinHint = null; Token tk = null; + String usingAlias; } { [ LOOKAHEAD({ isAsOfJoinAhead() }) { join.setAsOf(true); } ] @@ -12410,6 +12540,14 @@ Join JoinerExpression() #JoinerExpression: "(" tableColumn=Column() { columns = new ArrayList(); columns.add(tableColumn); } ( "," tableColumn=Column() { columns.add(tableColumn); } ) * ")" { join.setUsingColumns(columns); } + // PostgreSQL join_using_alias: a qualified join only, AS is required + [ LOOKAHEAD(2) usingAlias=RelObjectName() { + requireSyntax(!join.isNatural() && !join.isCross() + && !join.isSimple() && hasUnqualifiedColumns(columns), + "An alias of the USING columns requires a qualified join" + + " and plain column names"); + join.setUsingAlias(new Alias(usingAlias, true)); + } ] ) ) ] @@ -13367,6 +13505,7 @@ Expression Condition(): | LOOKAHEAD(3) result=IncludesExpression(left) | LOOKAHEAD(2) result=Between(left) | LOOKAHEAD(2) result = MemberOfExpression(left) + | LOOKAHEAD({ isIsNormalizedAhead() }) result=IsNormalizedExpression(left) | LOOKAHEAD(3) result=IsNullExpression(left) | LOOKAHEAD(3) result=IsBooleanExpression(left) | LOOKAHEAD(3) result=IsJsonExpression(left) @@ -13381,7 +13520,8 @@ Expression Condition(): ( LOOKAHEAD({ isPostfixIsTestAhead() }) ( - LOOKAHEAD(3) result=IsBooleanExpression(result) + LOOKAHEAD({ isIsNormalizedAhead() }) result=IsNormalizedExpression(result) + | LOOKAHEAD(3) result=IsBooleanExpression(result) | LOOKAHEAD(3) result=IsUnknownExpression(result) | LOOKAHEAD(3) result=IsNullExpression(result) | LOOKAHEAD(3) result=IsJsonExpression(result) @@ -13523,6 +13663,8 @@ Expression SQLCondition(): | result = MemberOfExpression(left) | + LOOKAHEAD({ isIsNormalizedAhead() }) result=IsNormalizedExpression(left) + | LOOKAHEAD(3) result=IsNullExpression(left) | LOOKAHEAD(3) result=IsBooleanExpression(left) @@ -13818,6 +13960,26 @@ Expression IsJsonExpression(Expression leftExpression): { result.setLeftExpression(leftExpression); return result; } } +Expression IsNormalizedExpression(Expression leftExpression): +{ + IsNormalizedExpression result = new IsNormalizedExpression(); + Token operator = getToken(1); + Token form; +} +{ + [ { result.setNot(true); } ] + [ LOOKAHEAD({ !isKeywordAhead("NORMALIZED") }) form= { + result.setNormalForm(IsNormalizedExpression.NormalForm.valueOf( + form.image.toUpperCase(Locale.ROOT))); + } ] + ContextualKeyword("NORMALIZED") + { + result.setLeftExpression(leftExpression); + linkAST(result, firstToken(leftExpression, operator)); + return result; + } +} + Expression IsUnknownExpression(Expression leftExpression): { IsUnknownExpression result = new IsUnknownExpression(); @@ -14377,8 +14539,12 @@ Expression PrimaryExpression() #PrimaryExpression: ExpressionList list; List> jsonIdents = null; + boolean timezoneOperand; + Token atToken; + Token timezoneFirst = null; } { + { timezoneOperand = timezoneOperandAhead; timezoneOperandAhead = false; } [ { not=true; } | "!" { not=true; exclamationMarkNot=true; } ] [ ( sign="+" | sign="-" | sign="~" ) @@ -14601,20 +14767,47 @@ Expression PrimaryExpression() #PrimaryExpression: retval = JsonExpression(retval, jsonIdents) ] - ( LOOKAHEAD(2) { linkASTIfMissing(retval, coreFirst); } timezoneRightExpr=PrimaryExpression() { - if (timezoneExpr == null) - timezoneExpr = new TimezoneExpression(); - - timezoneExpr.addTimezoneExpression(timezoneRightExpr); - } + // AT TIME ZONE zone and AT LOCAL chain from left to right: one TimezoneExpression holds a run + // of zones and an AT LOCAL that ends it, and a conversion after AT LOCAL wraps that expression + ( LOOKAHEAD(2, { !timezoneOperand }) { linkASTIfMissing(retval, coreFirst); } atToken= + ( + { timezoneOperandAhead = true; } + timezoneRightExpr=PrimaryExpression() { + if (timezoneExpr == null || timezoneExpr.isAtLocal()) { + timezoneExpr = new TimezoneExpression().setLeftExpression(retval); + retval = timezoneExpr; + timezoneFirst = atToken; + } + timezoneExpr.addTimezoneExpression(timezoneRightExpr); + // a run that was linked before this zone takes it in + widen(timezoneExpr, null, getToken(0)); + } + | + { + if (timezoneExpr == null || timezoneExpr.isAtLocal()) { + timezoneExpr = new TimezoneExpression().setLeftExpression(retval); + retval = timezoneExpr; + } + timezoneExpr.setAtLocal(true); + widen(timezoneExpr, null, getToken(0)); + linkTimezones(timezoneExpr, timezoneFirst); + } + // a cast applies to the converted value: ts AT LOCAL::date + ( LOOKAHEAD(2) { linkASTIfMissing(retval, coreFirst); } "::" type=ColDataType() { + castExpr = new CastExpression(); + castExpr.setUseCastKeyword(false); + castExpr.setLeftExpression(retval); + castExpr.setColDataType(type); + retval = castExpr; + timezoneExpr = null; + } + )* + ) )* { - if (timezoneExpr != null && !timezoneExpr.getTimezoneExpressions().isEmpty()) { - timezoneExpr.setLeftExpression(retval); - retval=timezoneExpr; - // the field behind this getter is an ExpressionList - linkElements((ExpressionList) timezoneExpr.getTimezoneExpressions()); + if (timezoneExpr != null) { + linkTimezones(timezoneExpr, timezoneFirst); } // each value gets a range node only once something wraps it, the outermost takes jjtThis Token valueFirst = coreFirst; @@ -16672,6 +16865,7 @@ ExpressionList FunctionArgumentList(Function retval): } { { if (hasUnqualifiedName(retval, "XMLFOREST")) { return XmlForestArguments(); } } + { if (isXmlElementAttributesAhead(retval)) { return XmlElementArguments(); } } [ LOOKAHEAD(2) extraKeywordToken = { retval.setExtraKeyword(extraKeywordToken.image); } ] expressionList=ExpressionList() // explicit lookahead: InternalFunction accepts ORDER BY after IGNORE NULLS too @@ -16703,14 +16897,61 @@ ExpressionList XmlForestArguments(): { return linkElements(arguments); } } +/** + * XMLELEMENT(NAME name, XMLATTRIBUTES(value [AS name], ...) [, content, ...]): the attribute list + * is the argument after the element name and is kept as a function over aliased expressions. + */ +ExpressionList XmlElementArguments(): +{ + ExpressionList arguments = new ExpressionList(); + Expression expression; + String name; +} +{ + ( + // PostgreSQL: the element name is an identifier, not a value + LOOKAHEAD({ isPostgreSqlDialect() }) + name=RelObjectName() { expression = linkToLastToken(new Column(name)); } + | + expression=Expression() + ) { arguments.add(expression); } + "," expression=XmlAttributes() { arguments.add(expression); } + ( "," expression=Expression() { arguments.add(expression); } )* + { + requireSyntax(getToken(1).kind == CLOSING_BRACKET, + "XMLELEMENT takes its name, XMLATTRIBUTES and content values only"); + return linkElements(arguments); + } +} + +Function XmlAttributes() #Function: +{ + Function function = new Function(); + ExpressionList arguments; + Token name; +} +{ + name= "(" arguments=XmlForestArguments() ")" + { + function.setName(name.image); + function.setParameters(arguments); + linkAST(function, jjtThis); + return function; + } +} + Expression XmlForestArgument(): { Expression expression; String name; + Token first = getToken(1); } { expression=Expression() - [ name=RelObjectName() { expression = new AliasedExpression(expression, new Alias(name, true)); } ] + [ name=RelObjectName() { + first = firstToken(expression, first); + expression = linked(new AliasedExpression(expression, new Alias(name, true)), first); + } ] { return expression; } } diff --git a/src/test/java/net/sf/jsqlparser/statement/select/PostgreSqlSelectFormsTest.java b/src/test/java/net/sf/jsqlparser/statement/select/PostgreSqlSelectFormsTest.java new file mode 100644 index 0000000000..e554b7d05e --- /dev/null +++ b/src/test/java/net/sf/jsqlparser/statement/select/PostgreSqlSelectFormsTest.java @@ -0,0 +1,645 @@ +/*- + * #%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.select; + +import static net.sf.jsqlparser.test.TestUtils.assertSqlCanBeParsedAndDeparsed; +import static net.sf.jsqlparser.util.validation.ValidationTestAsserts.validateNoErrors; +import static net.sf.jsqlparser.util.validation.ValidationTestAsserts.validateNotAllowed; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.BufferedReader; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.function.Consumer; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import net.sf.jsqlparser.JSQLParserException; +import net.sf.jsqlparser.expression.Alias; +import net.sf.jsqlparser.expression.AliasedExpression; +import net.sf.jsqlparser.expression.CastExpression; +import net.sf.jsqlparser.expression.Expression; +import net.sf.jsqlparser.expression.ExpressionVisitorAdapter; +import net.sf.jsqlparser.expression.Function; +import net.sf.jsqlparser.expression.StringValue; +import net.sf.jsqlparser.expression.TimezoneExpression; +import net.sf.jsqlparser.expression.operators.conditional.AndExpression; +import net.sf.jsqlparser.expression.operators.relational.IsBooleanExpression; +import net.sf.jsqlparser.expression.operators.relational.IsNormalizedExpression; +import net.sf.jsqlparser.expression.operators.relational.IsNormalizedExpression.NormalForm; +import net.sf.jsqlparser.expression.operators.relational.IsNullExpression; +import net.sf.jsqlparser.parser.ASTNodeAccess; +import net.sf.jsqlparser.parser.AbstractJSqlParser.Dialect; +import net.sf.jsqlparser.parser.CCJSqlParser; +import net.sf.jsqlparser.parser.CCJSqlParserUtil; +import net.sf.jsqlparser.parser.Node; +import net.sf.jsqlparser.parser.feature.Feature; +import net.sf.jsqlparser.schema.Column; +import net.sf.jsqlparser.schema.Table; +import net.sf.jsqlparser.statement.Statement; +import net.sf.jsqlparser.statement.Statements; +import net.sf.jsqlparser.statement.StmtFeature; +import net.sf.jsqlparser.statement.select.SelectIntoClause.Persistence; +import net.sf.jsqlparser.statement.select.SelectIntoClause.Position; +import net.sf.jsqlparser.util.TablesNamesFinder; +import net.sf.jsqlparser.util.deparser.ExpressionDeParser; +import net.sf.jsqlparser.util.deparser.SelectDeParser; +import net.sf.jsqlparser.util.deparser.StatementDeParser; +import net.sf.jsqlparser.util.validation.feature.DatabaseType; +import net.sf.jsqlparser.util.validation.feature.FeaturesAllowed; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.junit.jupiter.params.provider.MethodSource; +import org.junit.jupiter.params.provider.ValueSource; + +/** + * PostgreSQL SELECT forms: SELECT INTO with a persistence, the alias of JOIN USING, AT LOCAL, IS + * NORMALIZED, XMLATTRIBUTES in XMLELEMENT and the set quantifier of GROUP BY. + */ +class PostgreSqlSelectFormsTest { + private static final Consumer POSTGRESQL = + parser -> parser.withDialect(Dialect.POSTGRESQL); + + static Stream statements() throws Exception { + try (BufferedReader reader = new BufferedReader(new InputStreamReader( + PostgreSqlSelectFormsTest.class + .getResourceAsStream("/postgresql/select-forms.sql"), + StandardCharsets.UTF_8))) { + return reader.lines() + .filter(line -> !line.trim().isEmpty() && !line.trim().startsWith("--")) + .map(line -> line.substring(0, line.length() - 1)) + .collect(Collectors.toList()) + .stream(); + } + } + + private static PlainSelect parse(String sql) throws JSQLParserException { + return (PlainSelect) CCJSqlParserUtil.parse(sql, POSTGRESQL); + } + + private static PlainSelect parse(String sql, Dialect dialect) throws JSQLParserException { + return (PlainSelect) CCJSqlParserUtil.parse(sql, parser -> parser.withDialect(dialect)); + } + + private static Expression item(String sql) throws JSQLParserException { + return parse(sql).getSelectItem(0).getExpression(); + } + + private static String deparse(Statement statement) { + StringBuilder builder = new StringBuilder(); + statement.accept(new StatementDeParser(builder)); + return builder.toString(); + } + + private static String source(String sql, ASTNodeAccess part) { + Node node = part.getASTNode(); + return sql.substring(node.jjtGetFirstToken().absoluteBegin - 1, + node.jjtGetLastToken().absoluteEnd - 1); + } + + private static void assertRejected(String sql, Consumer consumer) { + assertThrows(JSQLParserException.class, () -> CCJSqlParserUtil.parse(sql, consumer), sql); + } + + @ParameterizedTest + @MethodSource("statements") + void testRoundTripWithAndWithoutDialect(String sql) throws Exception { + Statement statement = assertSqlCanBeParsedAndDeparsed(sql, false, POSTGRESQL); + assertEquals(sql, statement.toString()); + assertEquals(sql, deparse(statement)); + assertEquals(sql, CCJSqlParserUtil.parse(statement.toString(), POSTGRESQL).toString()); + + // the forms are not ambiguous, so that they are read without a dialect as well + assertEquals(sql, assertSqlCanBeParsedAndDeparsed(sql).toString()); + validateNoErrors(sql, 1, DatabaseType.POSTGRESQL); + } + + @ParameterizedTest + @ValueSource(strings = { + "SELECT a, count(*) FROM t GROUP BY ALL a", + "SELECT a, count(*) FROM t GROUP BY DISTINCT a", + "SELECT a, b, count(*) FROM t GROUP BY ALL a, b", + "SELECT a, b, count(*) FROM t GROUP BY DISTINCT ROLLUP(a, b), ROLLUP(a, c)", + "SELECT a, b, count(*) FROM t GROUP BY ALL ROLLUP(a, b)", + "SELECT a, b, count(*) FROM t GROUP BY DISTINCT CUBE(a, b)", + "SELECT a, b, count(*) FROM t GROUP BY ALL GROUPING SETS ((a), (b), ())", + "SELECT a, b, count(*) FROM t GROUP BY DISTINCT GROUPING SETS ((a, b), a, ())", + "SELECT a, b, count(*) FROM t GROUP BY DISTINCT a, ROLLUP(b), CUBE(c)", + "SELECT count(*) FROM t GROUP BY ALL ()", + "SELECT count(*) FROM t GROUP BY DISTINCT ()", + "SELECT a, count(*) FROM t GROUP BY DISTINCT (a)", + "SELECT a, b, count(*) FROM t GROUP BY ALL (a, b)", + "SELECT a, count(*) FROM t GROUP BY ALL a + 1, (a)", + "SELECT a, b, count(*) FROM t GROUP BY DISTINCT a, b HAVING count(*) > 1"}) + void testGroupBySetQuantifierRoundTrips(String sql) throws Exception { + PlainSelect select = (PlainSelect) assertSqlCanBeParsedAndDeparsed(sql, false, POSTGRESQL); + assertEquals(sql, select.toString()); + assertEquals(sql.contains("GROUP BY ALL"), select.getGroupBy().isAll()); + assertEquals(sql.contains("GROUP BY DISTINCT"), select.getGroupBy().isDistinct()); + } + + @ParameterizedTest + @ValueSource(strings = { + "SELECT a, count(*) FROM t GROUP BY ALL", + "SELECT a, count(*) FROM t GROUP BY DISTINCT", + "SELECT a, count(*) FROM t GROUP BY ALL DISTINCT a", + "SELECT a, count(*) FROM t GROUP BY DISTINCT ALL a", + "SELECT a, count(*) FROM t GROUP BY ALL ALL a", + "SELECT a, count(*) FROM t GROUP BY a ALL", + "SELECT a, count(*) FROM t GROUP BY UNIQUE a"}) + void testGroupBySetQuantifierRejected(String sql) { + assertRejected(sql, POSTGRESQL); + } + + @Test + void testSelectIntoPersistence() throws Exception { + for (Persistence persistence : Persistence.values()) { + for (boolean tableKeyword : new boolean[] {false, true}) { + String sql = "SELECT a INTO " + persistence.getKeywords() + + (tableKeyword ? " TABLE" : "") + " sch.t2 FROM t"; + PlainSelect select = parse(sql); + SelectIntoClause into = select.getIntoClause(); + assertEquals(persistence, into.getPersistence(), sql); + assertEquals(tableKeyword, into.isTableKeyword(), sql); + assertEquals(Position.BEFORE_FROM, into.getPosition()); + assertEquals("sch.t2", into.getTable().getFullyQualifiedName()); + assertEquals(persistence != Persistence.UNLOGGED, persistence.isTemporary()); + assertEquals(sql, select.toString()); + assertEquals(sql, deparse(select)); + // legacy views of the clause + assertSame(into.getTables(), select.getIntoTables()); + assertNull(select.getIntoTempTable()); + assertNull(select.getMySqlSelectIntoClause()); + } + } + assertEquals(Persistence.LOCAL_TEMP, Persistence.from("local temp")); + + SelectIntoClause plain = parse("SELECT * INTO TABLE t2 FROM t").getIntoClause(); + assertNull(plain.getPersistence()); + assertTrue(plain.isTableKeyword()); + + // without a name after it the word is the table + for (String name : Arrays.asList("temp", "temporary", "unlogged", "local", "global")) { + SelectIntoClause into = parse("SELECT * INTO " + name + " FROM t").getIntoClause(); + assertNull(into.getPersistence()); + assertEquals(name, into.getTable().getName()); + } + SelectIntoClause schema = parse("SELECT * INTO temp.t2 FROM t").getIntoClause(); + assertNull(schema.getPersistence()); + assertEquals("temp", schema.getTable().getSchemaName()); + + // an empty select list + assertSqlCanBeParsedAndDeparsed("SELECT INTO t2 FROM t", false, POSTGRESQL); + assertSqlCanBeParsedAndDeparsed("SELECT INTO TEMP t2 FROM t", false, POSTGRESQL); + } + + @Test + void testSelectIntoRangeTablesAndFeatures() throws Exception { + String sql = "SELECT a INTO LOCAL TEMP TABLE t2 FROM t WHERE a > 1"; + PlainSelect select = parse(sql); + assertEquals("INTO LOCAL TEMP TABLE t2", source(sql, select.getIntoClause())); + assertThat(new TablesNamesFinder<>().getTables((Statement) select)) + .containsExactlyInAnyOrder("t", "t2"); + assertThat(select.getFeatures().getCertain()) + .contains(StmtFeature.MODIFIES_SCHEMA, StmtFeature.MODIFIES_DATA, + StmtFeature.READS_DATA) + .doesNotContain(StmtFeature.RETURNS_RESULT_SET); + + List visited = new ArrayList<>(); + select.accept((SelectVisitor) new SelectVisitorAdapter( + new ExpressionVisitorAdapter(), new FromItemVisitorAdapter() { + @Override + public Void visit(Table table, S context) { + visited.add(table.getName()); + return null; + } + }), null); + assertThat(visited).containsExactly("t2", "t"); + + validateNotAllowed("SELECT * INTO UNLOGGED t2 FROM t", 1, 1, FeaturesAllowed.SELECT, + Feature.selectInto); + validateNoErrors("SELECT * INTO UNLOGGED t2 FROM t", 1, DatabaseType.POSTGRESQL); + } + + @ParameterizedTest + @ValueSource(strings = { + "SELECT * INTO TABLE FROM t", + "SELECT * INTO TEMP TABLE FROM t", + "SELECT * INTO LOCAL TEMP FROM t", + "SELECT * INTO UNLOGGED TEMP t2 FROM t", + "SELECT * INTO TEMP UNLOGGED t2 FROM t", + "SELECT * INTO LOCAL t2 FROM t", + "SELECT * INTO GLOBAL t2 FROM t", + "SELECT * INTO LOCAL UNLOGGED t2 FROM t", + "SELECT * INTO LOCAL TABLE t2 FROM t", + "SELECT * INTO TEMP TEMP t2 FROM t", + "SELECT * INTO TABLE TEMP t2 FROM t", + "SELECT * INTO TEMP t2, t3 FROM t", + "SELECT * INTO TABLE t2, t3 FROM t", + "SELECT * INTO t2, t3 FROM t", + "SELECT * INTO TEMP t2 AS x FROM t", + "SELECT * FROM t INTO TEMP t2", + "SELECT * FROM t INTO t2", + "SELECT * INTO TEMP TABLE t2 TABLE t3 FROM t", + "SELECT * INTO TEMP IF NOT EXISTS t2 FROM t", + "SELECT * INTO TEMP ONLY t2 FROM t", + "SELECT * INTO t2 INTO t3 FROM t"}) + void testSelectIntoRejected(String sql) { + assertRejected(sql, POSTGRESQL); + } + + @Test + void testSelectIntoInOtherDialects() throws Exception { + // the persistence and TABLE keywords belong to PostgreSQL + for (Dialect dialect : Arrays.asList(Dialect.ORACLE, Dialect.MYSQL, Dialect.SQLSERVER)) { + for (String sql : Arrays.asList("SELECT * INTO TEMP t2 FROM t", + "SELECT * INTO UNLOGGED t2 FROM t", "SELECT * INTO LOCAL TEMP t2 FROM t", + "SELECT * INTO TABLE t2 FROM t", "SELECT INTO t2 FROM t")) { + assertRejected(sql, parser -> parser.withDialect(dialect)); + } + SelectIntoClause into = parse("SELECT * INTO temp FROM t", dialect).getIntoClause(); + assertNull(into.getPersistence()); + assertEquals("temp", into.getTable().getName()); + } + + // target lists of other products and procedural languages + for (Consumer consumer : Arrays.>asList( + parser -> { + }, parser -> parser.withDialect(Dialect.ORACLE))) { + PlainSelect select = (PlainSelect) assertSqlCanBeParsedAndDeparsed( + "SELECT a, b INTO x, y FROM t", false, consumer); + assertThat(select.getIntoTables()).extracting(Table::getName).containsExactly("x", + "y"); + assertSqlCanBeParsedAndDeparsed("SELECT * INTO #tmp FROM t", false, consumer); + } + + // Informix writes INTO TEMP at the end + String informix = "SELECT a FROM t INTO TEMP t2 WITH NO LOG"; + PlainSelect select = (PlainSelect) assertSqlCanBeParsedAndDeparsed(informix); + assertEquals(Position.TRAILING, select.getIntoClause().getPosition()); + assertEquals(Persistence.TEMP, select.getIntoClause().getPersistence()); + assertEquals("t2", select.getIntoTempTable().getName()); + assertNull(select.getIntoTables()); + assertEquals("INTO TEMP t2", source(informix, select.getIntoClause())); + assertThat(new TablesNamesFinder<>().getTables((Statement) select)) + .containsExactlyInAnyOrder("t", "t2"); + assertRejected("SELECT a INTO t3 FROM t INTO TEMP t2", parser -> { + }); + assertRejected("SELECT a INTO OUTFILE 'f' FROM t INTO TEMP t2", parser -> { + }); + } + + @Test + void testSelectIntoLegacyAccessors() { + PlainSelect select = new PlainSelect().addSelectItems(new Column("a")) + .withFromItem(new Table("t")); + assertNull(select.getIntoClause()); + assertNull(select.getIntoTables()); + assertNull(select.getIntoTempTable()); + + select.addIntoTables(new Table("x")); + select.addIntoTables(new Table("y")); + assertEquals("SELECT a INTO x, y FROM t", select.toString()); + assertEquals(Position.BEFORE_FROM, select.getIntoClause().getPosition()); + + select.getIntoClause().setPersistence(Persistence.UNLOGGED); + select.setIntoTables(new ArrayList<>(Arrays.asList(new Table("z")))); + assertEquals("SELECT a INTO UNLOGGED z FROM t", select.toString()); + + select.setIntoTempTable(null); + assertEquals("SELECT a INTO UNLOGGED z FROM t", select.toString()); + select.setIntoTables(null); + assertNull(select.getIntoClause()); + + select.withIntoTempTable(new Table("tmp")); + assertEquals("SELECT a FROM t INTO TEMP tmp", select.toString()); + assertNull(select.getIntoTables()); + select.setIntoTables(null); + assertEquals("tmp", select.getIntoTempTable().getName()); + select.setIntoTempTable(null); + assertNull(select.getIntoClause()); + + select.withIntoClause(new SelectIntoClause(new Table("t2")) + .withPersistence(Persistence.GLOBAL_TEMPORARY).withTableKeyword(true)); + assertEquals("SELECT a INTO GLOBAL TEMPORARY TABLE t2 FROM t", select.toString()); + assertEquals("INTO GLOBAL TEMPORARY TABLE t2", select.getIntoClause().toString()); + assertEquals(new SelectIntoClause().withTables(new ArrayList<>()).addTable(new Table("q")) + .withPosition(Position.TRAILING).toString(), "INTO q"); + } + + @Test + void testJoinUsingAlias() throws Exception { + String sql = "SELECT j.id FROM a LEFT JOIN b USING (id, x) AS j JOIN c USING (id)"; + PlainSelect select = parse(sql); + Join join = select.getJoins().get(0); + assertEquals("j", join.getUsingAlias().getName()); + assertThat(join.getUsingColumns()).extracting(Column::getColumnName).containsExactly("id", + "x"); + assertNull(select.getJoins().get(1).getUsingAlias()); + assertEquals("LEFT JOIN b USING (id, x) AS j", source(sql, join)); + assertEquals(" USING (id, x) AS j", join.appendUsingTo(new StringBuilder()).toString()); + assertThat(new TablesNamesFinder<>().getTables((Statement) select)) + .containsExactlyInAnyOrder("a", "b", "c"); + + join.setUsingAlias(null); + assertEquals("SELECT j.id FROM a LEFT JOIN b USING (id, x) JOIN c USING (id)", + select.toString()); + select.getJoins().get(1).withUsingAlias(new Alias("k")); + assertEquals("SELECT j.id FROM a LEFT JOIN b USING (id, x) JOIN c USING (id) AS k", + deparse(select)); + // the alias belongs to the USING columns + assertEquals("", new Join().withUsingAlias(new Alias("k")) + .appendUsingTo(new StringBuilder()).toString()); + } + + @ParameterizedTest + @ValueSource(strings = { + "SELECT * FROM a JOIN b USING (id) j", + "SELECT * FROM a JOIN b USING (id) AS j (x)", + "SELECT * FROM a JOIN b USING (id) AS", + "SELECT * FROM a JOIN b ON a.id = b.id AS j", + "SELECT * FROM a CROSS JOIN b USING (id) AS j", + "SELECT * FROM a NATURAL JOIN b USING (id) AS j", + "SELECT * FROM a, b USING (id) AS j", + "SELECT * FROM a JOIN b USING (id) AS j AS k", + "SELECT * FROM a JOIN b USING (id) AS j ON true", + "SELECT * FROM a JOIN b USING (id) AS select", + "SELECT * FROM a JOIN b USING (a.id) AS j", + "SELECT * FROM a JOIN b USING () AS j"}) + void testJoinUsingAliasRejected(String sql) { + assertRejected(sql, POSTGRESQL); + assertRejected(sql, parser -> { + }); + } + + @Test + void testAtLocal() throws Exception { + String sql = "SELECT ts AT LOCAL FROM t"; + TimezoneExpression local = (TimezoneExpression) item(sql); + assertTrue(local.isAtLocal()); + assertThat(local.getTimezoneExpressions()).isEmpty(); + assertEquals("ts", local.getLeftExpression().toString()); + assertEquals("ts AT LOCAL", source(sql, local)); + + // AT LOCAL ends a run of zones + sql = "SELECT ts AT TIME ZONE 'UTC' AT TIME ZONE 'CET' AT LOCAL FROM t"; + TimezoneExpression zones = (TimezoneExpression) item(sql); + assertTrue(zones.isAtLocal()); + assertThat(zones.getTimezoneExpressions()).extracting(Object::toString) + .containsExactly("'UTC'", "'CET'"); + assertThat(zones.getTimezoneExpressions()).allMatch(StringValue.class::isInstance); + assertEquals("ts AT TIME ZONE 'UTC' AT TIME ZONE 'CET' AT LOCAL", source(sql, zones)); + + // and a conversion after it applies to the local value + sql = "SELECT ts AT LOCAL AT TIME ZONE 'UTC' AT LOCAL AT LOCAL FROM t"; + TimezoneExpression outer = (TimezoneExpression) item(sql); + assertTrue(outer.isAtLocal()); + assertThat(outer.getTimezoneExpressions()).isEmpty(); + TimezoneExpression middle = (TimezoneExpression) outer.getLeftExpression(); + assertTrue(middle.isAtLocal()); + assertThat(middle.getTimezoneExpressions()).hasSize(1); + assertEquals("ts AT LOCAL AT TIME ZONE 'UTC' AT LOCAL", source(sql, middle)); + TimezoneExpression inner = (TimezoneExpression) middle.getLeftExpression(); + assertTrue(inner.isAtLocal()); + assertEquals("ts AT LOCAL", source(sql, inner)); + + sql = "SELECT ts AT LOCAL::date AT LOCAL FROM t"; + TimezoneExpression afterCast = (TimezoneExpression) item(sql); + CastExpression cast = (CastExpression) afterCast.getLeftExpression(); + assertEquals("ts AT LOCAL::date", source(sql, cast)); + assertTrue(((TimezoneExpression) cast.getLeftExpression()).isAtLocal()); + + TimezoneExpression plain = (TimezoneExpression) item("SELECT ts AT TIME ZONE 'UTC' FROM t"); + assertFalse(plain.isAtLocal()); + assertEquals("ts AT TIME ZONE 'UTC' AT LOCAL", plain.setAtLocal(true).toString()); + assertEquals("now() AT LOCAL", + new TimezoneExpression(new Function().withName("now")).setAtLocal(true) + .toString()); + + // AT and LOCAL stay identifiers + assertEquals("at", parse("SELECT ts at FROM t").getSelectItem(0).getAlias().getName()); + assertSqlCanBeParsedAndDeparsed("SELECT at, local FROM t", false, POSTGRESQL); + assertEquals("ZONE", parse("SELECT ts AT LOCAL ZONE FROM t").getSelectItem(0).getAlias() + .getName()); + + assertThat(new TablesNamesFinder<>() + .getTables( + CCJSqlParserUtil.parse("SELECT (SELECT max(ts) FROM u) AT LOCAL FROM t"))) + .containsExactlyInAnyOrder("t", "u"); + for (Dialect dialect : Arrays.asList(Dialect.ORACLE, Dialect.MYSQL)) { + assertSqlCanBeParsedAndDeparsed("SELECT ts AT LOCAL FROM t", false, + parser -> parser.withDialect(dialect)); + } + } + + @ParameterizedTest + @ValueSource(strings = { + "SELECT ts AT LOCAL TIME ZONE 'UTC' FROM t", + "SELECT ts AT TIME LOCAL FROM t", + "SELECT ts AT ZONE 'UTC' FROM t", + "SELECT ts AT LOCAL LOCAL x FROM t"}) + void testAtLocalRejected(String sql) { + assertRejected(sql, POSTGRESQL); + } + + @Test + void testIsNormalized() throws Exception { + for (boolean not : new boolean[] {false, true}) { + List forms = new ArrayList<>(Arrays.asList(NormalForm.values())); + forms.add(null); + for (NormalForm form : forms) { + String sql = "SELECT s IS " + (not ? "NOT " : "") + + (form == null ? "" : form + " ") + "NORMALIZED FROM t"; + IsNormalizedExpression test = (IsNormalizedExpression) item(sql); + assertEquals(not, test.isNot(), sql); + assertEquals(form, test.getNormalForm(), sql); + assertEquals("s", test.getLeftExpression(Column.class).getColumnName()); + assertEquals(sql.substring(7, sql.length() - 7), source(sql, test)); + assertEquals(sql, deparse(parse(sql))); + } + } + assertEquals(NormalForm.NFKD, + ((IsNormalizedExpression) item("SELECT s is nfkd normalized FROM t")) + .getNormalForm()); + assertTrue(NormalForm.isNormalForm("nfc")); + assertFalse(NormalForm.isNormalForm("nfx")); + + // the test takes the whole operand and chains like the other IS tests + String sql = "SELECT s || 'x' IS NFKC NORMALIZED IS NOT TRUE FROM t"; + IsBooleanExpression chained = (IsBooleanExpression) item(sql); + IsNormalizedExpression test = (IsNormalizedExpression) chained.getLeftExpression(); + assertEquals("s || 'x'", test.getLeftExpression().toString()); + assertEquals("s || 'x' IS NFKC NORMALIZED", source(sql, test)); + + IsNormalizedExpression afterNull = + (IsNormalizedExpression) item("SELECT s IS NOT NULL IS NORMALIZED FROM t"); + assertThat(afterNull.getLeftExpression()).isInstanceOf(IsNullExpression.class); + + AndExpression and = (AndExpression) parse( + "SELECT * FROM t WHERE s IS NFD NORMALIZED AND u IS NOT NORMALIZED").getWhere(); + assertThat(and.getLeftExpression()).isInstanceOf(IsNormalizedExpression.class); + assertThat(and.getRightExpression()).isInstanceOf(IsNormalizedExpression.class); + + assertEquals("'a' IS NOT NFD NORMALIZED", new IsNormalizedExpression(new StringValue("a")) + .withNot(true).withNormalForm(NormalForm.NFD).toString()); + assertEquals("b IS NORMALIZED", + new IsNormalizedExpression().withLeftExpression(new Column("b")).toString()); + + // the words stay identifiers + assertSqlCanBeParsedAndDeparsed("SELECT nfc, nfd, nfkc, nfkd, normalized FROM normalized", + false, POSTGRESQL); + assertEquals("NFC", parse("SELECT s IS NORMALIZED NFC FROM t").getSelectItem(0).getAlias() + .getName()); + for (Dialect dialect : Arrays.asList(Dialect.ORACLE, Dialect.MYSQL)) { + assertSqlCanBeParsedAndDeparsed("SELECT s IS NOT NFC NORMALIZED FROM t", false, + parser -> parser.withDialect(dialect)); + } + } + + @Test + void testIsNormalizedVisitors() throws Exception { + String sql = "SELECT (SELECT max(s) FROM u) IS NFC NORMALIZED FROM t"; + PlainSelect select = parse(sql); + assertThat(new TablesNamesFinder<>().getTables((Statement) select)) + .containsExactlyInAnyOrder("t", "u"); + + List columns = new ArrayList<>(); + ExpressionVisitorAdapter adapter = new ExpressionVisitorAdapter() { + @Override + public Void visit(Column column, S context) { + columns.add(column.getColumnName()); + return null; + } + }; + Expression test = item("SELECT s IS NORMALIZED FROM t"); + test.accept(adapter, null); + adapter.visit((IsNormalizedExpression) test); + assertThat(columns).containsExactly("s", "s"); + + StringBuilder builder = new StringBuilder(); + ExpressionDeParser expressionDeParser = new ExpressionDeParser() { + @Override + public StringBuilder visit(Column column, S context) { + return builder.append('<').append(column.getColumnName()).append('>'); + } + }; + SelectDeParser selectDeParser = new SelectDeParser(expressionDeParser, builder); + expressionDeParser.setSelectVisitor(selectDeParser); + expressionDeParser.setBuilder(builder); + parse("SELECT s IS NOT NFD NORMALIZED, ts AT TIME ZONE z AT LOCAL FROM t") + .accept((SelectVisitor) selectDeParser, null); + assertEquals("SELECT IS NOT NFD NORMALIZED, AT TIME ZONE AT LOCAL FROM t", + builder.toString()); + } + + @ParameterizedTest + @ValueSource(strings = { + "SELECT s IS NFX NORMALIZED FROM t", + "SELECT s IS NFC FROM t", + "SELECT s IS NFC NOT NORMALIZED FROM t", + "SELECT s IS NOT NOT NORMALIZED FROM t", + "SELECT s IS 'NFC' NORMALIZED FROM t", + "SELECT s IS NFC NFD NORMALIZED FROM t", + "SELECT s NOT IS NORMALIZED FROM t", + "SELECT s IS \"NFC\" NORMALIZED FROM t"}) + void testIsNormalizedRejected(String sql) { + assertRejected(sql, POSTGRESQL); + assertRejected(sql, parser -> { + }); + } + + @Test + void testXmlAttributes() throws Exception { + String sql = "SELECT xmlelement(name foo, xmlattributes(a AS x, t.b, a + 1 AS \"y z\")," + + " 'content', s) FROM t"; + Function element = (Function) item(sql); + assertEquals("name", element.getExtraKeyword()); + assertThat(element.getParameters()).hasSize(4); + assertEquals("foo", element.getParameters().get(0).toString()); + Function attributes = (Function) element.getParameters().get(1); + assertEquals("xmlattributes", attributes.getName()); + assertEquals("xmlattributes(a AS x, t.b, a + 1 AS \"y z\")", source(sql, attributes)); + AliasedExpression first = (AliasedExpression) attributes.getParameters().get(0); + assertEquals("x", first.getAlias().getName()); + assertEquals("a", first.getExpression().toString()); + assertThat(attributes.getParameters().get(1)).isInstanceOf(Column.class); + assertEquals("\"y z\"", + ((AliasedExpression) attributes.getParameters().get(2)).getAlias().getName()); + assertEquals("a AS x", source(sql, first)); + + sql = "SELECT xmlelement(name a, xmlattributes((SELECT max(c) FROM u) AS m)," + + " xmlelement(name b, xmlattributes(v.d AS n))) FROM t, v"; + assertThat(new TablesNamesFinder<>().getTables(CCJSqlParserUtil.parse(sql, POSTGRESQL))) + .containsExactlyInAnyOrder("t", "u", "v"); + + // other products write the element name without NAME + for (Consumer consumer : Arrays.>asList( + parser -> { + }, parser -> parser.withDialect(Dialect.ORACLE))) { + assertSqlCanBeParsedAndDeparsed( + "SELECT XMLELEMENT(\"Emp\", XMLATTRIBUTES(e.id AS \"id\"), e.name) FROM e", + false, consumer); + } + // the content and the plain function call are read as before + assertSqlCanBeParsedAndDeparsed("SELECT xmlelement(name foo, s, a) FROM t", false, + POSTGRESQL); + assertSqlCanBeParsedAndDeparsed("SELECT xmlforest(a AS x, b) FROM t", false, POSTGRESQL); + } + + @ParameterizedTest + @ValueSource(strings = { + "SELECT xmlelement(name foo, xmlattributes()) FROM t", + "SELECT xmlelement(name foo, xmlattributes(a x)) FROM t", + "SELECT xmlelement(name foo, 'content', xmlattributes(a AS x)) FROM t", + "SELECT xmlelement(name foo, xmlattributes(a AS x), xmlattributes(b AS y)) FROM t", + "SELECT xmlelement(foo, xmlattributes(a AS x)) FROM t", + "SELECT xmlelement(name 'foo', xmlattributes(a AS x)) FROM t", + "SELECT xmlelement(name foo, xmlattributes(a AS 'x')) FROM t", + "SELECT xmlelement(name foo, xmlattributes(a AS x.y)) FROM t", + "SELECT xmlattributes(a AS x) FROM t", + "SELECT xmlelement(name foo xmlattributes(a AS x)) FROM t", + "SELECT xmlelement(name foo, xmlattributes(a AS x),) FROM t", + "SELECT xmlelement(name foo, xmlattributes(a AS x), b AS y) FROM t", + "SELECT xmlelement(name foo, xmlattributes(DISTINCT a AS x)) FROM t", + "SELECT xmlelement(name foo, xmlattributes(a AS x) ORDER BY a) FROM t", + "SELECT xmlelement(name foo, s AS x) FROM t"}) + void testXmlAttributesRejected(String sql) { + assertRejected(sql, POSTGRESQL); + } + + @Test + void testStatementBoundaries() throws Exception { + String script = "SELECT * INTO TEMP t2 FROM t; SELECT * FROM a JOIN b USING (id) AS j;" + + " SELECT ts AT LOCAL; SELECT s IS NFC NORMALIZED FROM t;" + + " SELECT xmlelement(name foo, xmlattributes(a AS x)) FROM t;" + + " SELECT a FROM t GROUP BY ALL a; SELECT 1"; + Statements statements = CCJSqlParserUtil.parseStatements(script, POSTGRESQL); + assertThat(statements).hasSize(7).allMatch(PlainSelect.class::isInstance); + assertEquals(script + ";", statements.toString().trim().replace(";\n", "; ")); + assertEquals(Persistence.TEMP, + ((PlainSelect) statements.get(0)).getIntoClause().getPersistence()); + assertNull(((PlainSelect) statements.get(6)).getIntoClause()); + } + + @ParameterizedTest + @EnumSource(value = Dialect.class, names = {"ORACLE", "MYSQL", "SQLSERVER"}) + void testEmptySelectListIntoBelongsToPostgreSql(Dialect dialect) { + assertRejected("SELECT INTO t2 FROM t", parser -> parser.withDialect(dialect)); + } +} diff --git a/src/test/resources/postgresql/select-forms.sql b/src/test/resources/postgresql/select-forms.sql new file mode 100644 index 0000000000..1e807801fb --- /dev/null +++ b/src/test/resources/postgresql/select-forms.sql @@ -0,0 +1,122 @@ +--- +-- #%L +-- JSQLParser library +-- %% +-- Copyright (C) 2004 - 2026 JSQLParser +-- %% +-- Dual licensed under GNU LGPL 2.1 or Apache License 2.0 +-- #L% +--- +SELECT * INTO i01 FROM t; +SELECT * INTO TABLE i02 FROM t; +SELECT * INTO TEMP i03 FROM t; +SELECT * INTO TEMPORARY i04 FROM t; +SELECT * INTO TEMP TABLE i05 FROM t; +SELECT * INTO TEMPORARY TABLE i06 FROM t; +SELECT * INTO LOCAL TEMP i07 FROM t; +SELECT * INTO LOCAL TEMPORARY i08 FROM t; +SELECT * INTO LOCAL TEMP TABLE i09 FROM t; +SELECT * INTO LOCAL TEMPORARY TABLE i10 FROM t; +SELECT * INTO GLOBAL TEMP i11 FROM t; +SELECT * INTO GLOBAL TEMPORARY i12 FROM t; +SELECT * INTO GLOBAL TEMP TABLE i13 FROM t; +SELECT * INTO GLOBAL TEMPORARY TABLE i14 FROM t; +SELECT * INTO UNLOGGED i15 FROM t; +SELECT * INTO UNLOGGED TABLE i16 FROM t; +SELECT * INTO sch.i17 FROM t; +SELECT * INTO UNLOGGED sch.i18 FROM t; +SELECT * INTO TABLE sch.i19 FROM t; +SELECT * INTO TEMP sch.i20 FROM t; +SELECT a, b INTO TEMP i21 FROM t WHERE a > 1 GROUP BY a, b ORDER BY a LIMIT 3; +SELECT DISTINCT a INTO TEMP i22 FROM t; +SELECT 1 AS x INTO TEMP i23; +SELECT a INTO UNLOGGED i24 FROM t UNION SELECT b FROM t; +WITH w AS (SELECT 1 AS x) SELECT x INTO TEMP i25 FROM w; +SELECT * INTO TEMP temp FROM t; +SELECT * INTO TEMP "Mixed" FROM t; +SELECT * INTO TABLE temp FROM t; +SELECT * INTO TEMP unlogged FROM t; +SELECT * INTO temp FROM t; +SELECT * INTO temporary FROM t; +SELECT * INTO unlogged FROM t; +SELECT * INTO local FROM t; +SELECT * INTO global FROM t; +SELECT * INTO temp.i26 FROM t; +SELECT 1 INTO temp; +SELECT * FROM a JOIN b USING (id) AS j; +SELECT j.id FROM a JOIN b USING (id) AS j; +SELECT * FROM a INNER JOIN b USING (id) AS j; +SELECT * FROM a LEFT JOIN b USING (id) AS j; +SELECT * FROM a RIGHT OUTER JOIN b USING (id) AS j; +SELECT * FROM a FULL JOIN b USING (id) AS j; +SELECT * FROM a JOIN b USING (id, x) AS j; +SELECT * FROM a JOIN b USING (id) AS j JOIN c USING (id) AS k; +SELECT * FROM a JOIN b USING (id) AS j WHERE j.id = 1; +SELECT * FROM a JOIN b USING (id) AS "J"; +SELECT * FROM a JOIN b AS bb USING (id) AS j; +SELECT * FROM (a JOIN b USING (id) AS j) AS p; +SELECT * FROM a JOIN b USING (id) AS j, c; +SELECT * FROM a JOIN b USING (id) AS j ORDER BY 1; +SELECT * FROM a JOIN b USING (id) AS local; +SELECT ts AT LOCAL FROM t; +SELECT ts AT LOCAL AS l FROM t; +SELECT ts AT TIME ZONE 'UTC' AT LOCAL FROM t; +SELECT ts AT LOCAL AT TIME ZONE 'UTC' FROM t; +SELECT ts AT LOCAL AT LOCAL FROM t; +SELECT ts AT TIME ZONE 'UTC' AT LOCAL AT TIME ZONE 'CET' AT TIME ZONE 'UTC' AT LOCAL FROM t; +SELECT (ts AT LOCAL)::date FROM t; +SELECT ts AT LOCAL::date FROM t; +SELECT ts AT LOCAL::date::text AT LOCAL FROM t; +SELECT ts::timestamp AT LOCAL FROM t; +SELECT now() AT LOCAL; +SELECT TIMESTAMP '2024-01-01 00:00:00' AT LOCAL; +SELECT * FROM t WHERE ts AT LOCAL > now() AT LOCAL; +SELECT ts AT LOCAL + INTERVAL '1 day' FROM t; +SELECT ts + INTERVAL '1 day' AT LOCAL FROM t; +SELECT a FROM t ORDER BY ts AT LOCAL; +SELECT ts AT LOCAL local FROM t; +SELECT CURRENT_TIMESTAMP AT LOCAL; +SELECT -ts AT LOCAL FROM t; +SELECT s IS NORMALIZED FROM t; +SELECT s IS NOT NORMALIZED FROM t; +SELECT s IS NFC NORMALIZED FROM t; +SELECT s IS NFD NORMALIZED FROM t; +SELECT s IS NFKC NORMALIZED FROM t; +SELECT s IS NFKD NORMALIZED FROM t; +SELECT s IS NOT NFC NORMALIZED FROM t; +SELECT s IS NOT NFD NORMALIZED FROM t; +SELECT s IS NOT NFKC NORMALIZED FROM t; +SELECT s IS NOT NFKD NORMALIZED FROM t; +SELECT * FROM t WHERE s IS NFC NORMALIZED; +SELECT * FROM t WHERE s IS NOT NORMALIZED AND a = 1; +SELECT * FROM t WHERE NOT s IS NORMALIZED OR s IS NULL; +SELECT NOT s IS NFC NORMALIZED FROM t; +SELECT 'abc' IS NORMALIZED; +SELECT (s || 'x') IS NFKC NORMALIZED FROM t; +SELECT s || 'x' IS NFKC NORMALIZED FROM t; +SELECT lower(s) IS NFD NORMALIZED AS n FROM t; +SELECT CASE WHEN s IS NORMALIZED THEN 1 ELSE 0 END FROM t; +SELECT s IS NORMALIZED IS TRUE FROM t; +SELECT s IS NOT NFC NORMALIZED IS NOT NULL FROM t; +SELECT s IS NOT NULL IS NORMALIZED FROM t; +SELECT s COLLATE "C" IS NFC NORMALIZED FROM t; +SELECT s IS NORMALIZED AND s IS NFD NORMALIZED FROM t; +SELECT a BETWEEN 1 AND 2 IS NORMALIZED FROM t; +SELECT s LIKE 'a' IS NORMALIZED FROM t; +SELECT a = 1 IS NORMALIZED FROM t; +SELECT (s IS NORMALIZED) = true FROM t; +SELECT s IS NORMALIZED normalized FROM t; +SELECT s IS NORMALIZED NFC FROM t; +SELECT nfc, normalized FROM (SELECT 1 AS nfc, 2 AS normalized) x; +SELECT xmlelement(name foo, xmlattributes(a AS x, b)) FROM t; +SELECT xmlelement(name foo, xmlattributes(a AS x)) FROM t; +SELECT xmlelement(name foo, xmlattributes(a, b, c)) FROM t; +SELECT xmlelement(name foo, xmlattributes(a AS x, b AS y), 'content') FROM t; +SELECT xmlelement(name foo, xmlattributes(a AS x), 'content', s, a + 1) FROM t; +SELECT xmlelement(name "foo$bar", xmlattributes('xyz' AS "a&b")) FROM t; +SELECT xmlelement(name foo, xmlattributes(a + 1 AS x, lower(s) AS y)) FROM t; +SELECT xmlelement(name foo, xmlattributes(current_date AS bar), 'cont', 'ent'); +SELECT xmlelement(name foo, xmlattributes(a AS x), xmlelement(name bar, xmlattributes(b AS y), s)) FROM t; +SELECT xmlelement(NAME foo, XMLATTRIBUTES(a AS x)) FROM t; +SELECT xmlelement(name foo, xmlattributes(t.a, t.b AS y)) FROM t; +SELECT xmlelement(name foo, xmlattributes(a AS x)) AS e FROM t WHERE a = 1;