From 4f161e31e518fbbb59fdbf1b9e1de95802d05c57 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BB=98=E5=85=B8?= Date: Fri, 2 Oct 2026 00:46:46 +0800 Subject: [PATCH] feat(parser): support ClickHouse transformer STRICT modifier and EXCEPT regex patterns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: 付典 --- .../expression/ColumnsTransformer.java | 48 +++- .../util/deparser/ExpressionDeParser.java | 18 +- .../net/sf/jsqlparser/parser/JSqlParserCC.jjt | 58 +++-- .../expression/ColumnsTransformerTest.java | 112 +++++++++ .../statement/select/ClickHouseTest.java | 224 ++++++++++++++++++ 5 files changed, 437 insertions(+), 23 deletions(-) create mode 100644 src/test/java/net/sf/jsqlparser/expression/ColumnsTransformerTest.java diff --git a/src/main/java/net/sf/jsqlparser/expression/ColumnsTransformer.java b/src/main/java/net/sf/jsqlparser/expression/ColumnsTransformer.java index 8fab9330e..d411939f6 100644 --- a/src/main/java/net/sf/jsqlparser/expression/ColumnsTransformer.java +++ b/src/main/java/net/sf/jsqlparser/expression/ColumnsTransformer.java @@ -31,7 +31,9 @@ public enum ColumnsTransformerType { private ColumnsTransformerType type; private Expression applyExpression; private ParenthesedExpressionList exceptColumns; + private StringValue exceptPattern; private List> replaceItems; + private boolean strict; public ColumnsTransformer(ColumnsTransformerType type) { this.type = type; @@ -61,6 +63,39 @@ public ParenthesedExpressionList getExceptColumns() { public ColumnsTransformer setExceptColumns(ParenthesedExpressionList exceptColumns) { this.exceptColumns = exceptColumns; + if (exceptColumns != null) { + this.exceptPattern = null; + } + return this; + } + + /** + * The re2 pattern of the ClickHouse {@code EXCEPT 'pattern'} form, mutually exclusive with + * {@link #getExceptColumns()}. + */ + public StringValue getExceptPattern() { + return exceptPattern; + } + + public ColumnsTransformer setExceptPattern(StringValue exceptPattern) { + this.exceptPattern = exceptPattern; + if (exceptPattern != null) { + this.exceptColumns = null; + } + return this; + } + + /** + * The {@code STRICT} modifier of ClickHouse {@code EXCEPT}/{@code REPLACE} transformers: named + * columns must exist, otherwise the database rejects the query. Meaningless for {@code APPLY} + * and {@code EXCLUDE}. + */ + public boolean isStrict() { + return strict; + } + + public ColumnsTransformer setStrict(boolean strict) { + this.strict = strict; return this; } @@ -81,6 +116,13 @@ void collectExpressions(List expressions) { } break; case EXCEPT: + if (exceptPattern != null) { + expressions.add(exceptPattern); + } + if (exceptColumns != null) { + expressions.addAll(exceptColumns); + } + break; case EXCLUDE: if (exceptColumns != null) { expressions.addAll(exceptColumns); @@ -104,13 +146,15 @@ public StringBuilder appendTo(StringBuilder builder) { builder.append("APPLY(").append(applyExpression).append(")"); break; case EXCEPT: - builder.append("EXCEPT ").append(exceptColumns); + builder.append("EXCEPT").append(strict ? " STRICT" : "").append(" ") + .append(exceptPattern != null ? exceptPattern : exceptColumns); break; case EXCLUDE: builder.append("EXCLUDE ").append(exceptColumns); break; case REPLACE: - builder.append("REPLACE(").append(Select.getStringList(replaceItems)).append(")"); + builder.append("REPLACE").append(strict ? " STRICT" : "").append("(") + .append(Select.getStringList(replaceItems)).append(")"); break; default: throw new IllegalStateException("Unhandled ColumnsTransformerType: " + type); 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 18c7b1472..3ef5620c5 100644 --- a/src/main/java/net/sf/jsqlparser/util/deparser/ExpressionDeParser.java +++ b/src/main/java/net/sf/jsqlparser/util/deparser/ExpressionDeParser.java @@ -1870,11 +1870,23 @@ public StringBuilder visit(ColumnsExpression columnsExpression, S context) { builder.append(")"); break; case EXCEPT: - builder.append(" EXCEPT "); - transformer.getExceptColumns().accept(this, context); + builder.append(" EXCEPT"); + if (transformer.isStrict()) { + builder.append(" STRICT"); + } + builder.append(" "); + if (transformer.getExceptPattern() != null) { + transformer.getExceptPattern().accept(this, context); + } else { + transformer.getExceptColumns().accept(this, context); + } break; case REPLACE: - builder.append(" REPLACE("); + builder.append(" REPLACE"); + if (transformer.isStrict()) { + builder.append(" STRICT"); + } + builder.append("("); boolean first = true; for (SelectItem item : transformer.getReplaceItems()) { if (!first) { diff --git a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt index c94af8730..1dce03f37 100644 --- a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt +++ b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt @@ -8252,8 +8252,9 @@ boolean ColumnsTransformerAhead() #void: { LOOKAHEAD( "(" RelObjectName() ")" ) { return true; } | LOOKAHEAD( ( | ) AliasColumns() ) { return false; } - | LOOKAHEAD( "(" | "(" - | ( | ) ParenthesedColumnList() ) + | LOOKAHEAD( "(" | [ ] "(" + | [ ] ( ParenthesedColumnList() | ) + | ParenthesedColumnList() ) { return true; } | { return false; } } @@ -8271,8 +8272,9 @@ boolean AllColumnsTransformerAhead() #void: { LOOKAHEAD( "(" RelObjectName() ")" ) { return true; } | LOOKAHEAD( AliasColumns() ) { return false; } - | LOOKAHEAD( "(" | "(" - | ( | ) ParenthesedColumnList() ) + | LOOKAHEAD( "(" | [ ] "(" + | [ ] ( ParenthesedColumnList() | ) + | ParenthesedColumnList() ) { return true; } | { return false; } } @@ -8298,7 +8300,8 @@ Expression SelectItemExpression() #ColumnsExpression(transformers != null): ( expression = Condition() { expression = prattExpressionRest(expression, 2); } ) ) ( - LOOKAHEAD( "(" | "(" | ParenthesedColumnList() ) + LOOKAHEAD( "(" | [ ] "(" + | [ ] ( ParenthesedColumnList() | ) ) { if (!(expression instanceof Function)) { break; @@ -8330,17 +8333,19 @@ Expression SelectItemExpression() #ColumnsExpression(transformers != null): } /** - * Parses a single ClickHouse transformer: APPLY(expression), EXCEPT (columns) or - * REPLACE (selectItems). ClickHouse parses these in a loop, so they may repeat - * and combine in any order. + * Parses a single ClickHouse transformer: APPLY(expression), EXCEPT [STRICT] (columns) + * or a pattern, REPLACE [STRICT] (selectItems). ClickHouse parses these in a loop, so + * they may repeat and combine in any order. */ ColumnsTransformer ColumnsTransformer(): { Expression applyExpression; - ParenthesedExpressionList exceptColumns; + ParenthesedExpressionList exceptColumns = null; + StringValue exceptPattern = null; List> replaceItems; ColumnsTransformer transformer; Token tk; + boolean strict; } { ( @@ -8354,16 +8359,32 @@ ColumnsTransformer ColumnsTransformer(): { transformer = new ColumnsTransformer(ColumnsTransformer.ColumnsTransformerType.APPLY) .setApplyExpression(applyExpression); } - | ( tk= | tk= ) exceptColumns = ParenthesedColumnList() - { transformer = new ColumnsTransformer( - tk.kind == K_EXCLUDE - ? ColumnsTransformer.ColumnsTransformerType.EXCLUDE - : ColumnsTransformer.ColumnsTransformerType.EXCEPT) + | + { strict = false; } + [ { strict = true; } ] + ( + exceptColumns = ParenthesedColumnList() + | + tk= { exceptPattern = new StringValue(tk.image); } + ) + { transformer = new ColumnsTransformer(ColumnsTransformer.ColumnsTransformerType.EXCEPT) + .setStrict(strict); + if (exceptPattern != null) { + transformer.setExceptPattern(exceptPattern); + } else { + transformer.setExceptColumns(exceptColumns); + } } + + | exceptColumns = ParenthesedColumnList() + { transformer = new ColumnsTransformer(ColumnsTransformer.ColumnsTransformerType.EXCLUDE) .setExceptColumns(exceptColumns); } - | "(" replaceItems = SelectItemsList() ")" + | + { strict = false; } + [ { strict = true; } ] + "(" replaceItems = SelectItemsList() ")" { transformer = new ColumnsTransformer(ColumnsTransformer.ColumnsTransformerType.REPLACE) - .setReplaceItems(replaceItems); } + .setStrict(strict).setReplaceItems(replaceItems); } ) { return transformer; } } @@ -8383,8 +8404,9 @@ AllColumns AllColumns(boolean allowAdditions): // BigData allows EXCEPT, DuckDB allows EXCLUDE, ClickHouse allows APPLY; // they may repeat and combine in any order ( - LOOKAHEAD( "(" | "(" - | ( | ) ParenthesedColumnList() ) + LOOKAHEAD( "(" | [ ] "(" + | [ ] ( ParenthesedColumnList() | ) + | ParenthesedColumnList() ) { if (!allowAdditions || !AllColumnsTransformerAhead()) { break; diff --git a/src/test/java/net/sf/jsqlparser/expression/ColumnsTransformerTest.java b/src/test/java/net/sf/jsqlparser/expression/ColumnsTransformerTest.java new file mode 100644 index 000000000..44efd7e81 --- /dev/null +++ b/src/test/java/net/sf/jsqlparser/expression/ColumnsTransformerTest.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; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +import java.util.ArrayList; +import java.util.List; +import net.sf.jsqlparser.JSQLParserException; +import net.sf.jsqlparser.expression.operators.relational.ParenthesedExpressionList; +import net.sf.jsqlparser.parser.CCJSqlParserUtil; +import net.sf.jsqlparser.schema.Column; +import net.sf.jsqlparser.statement.select.AllColumns; +import net.sf.jsqlparser.statement.select.PlainSelect; +import net.sf.jsqlparser.util.deparser.StatementDeParser; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +class ColumnsTransformerTest { + + @ParameterizedTest + @ValueSource(strings = {"*", "t.*", "COLUMNS('m')"}) + void testExceptColumnsReplacePattern(String matcher) throws JSQLParserException { + PlainSelect select = (PlainSelect) CCJSqlParserUtil.parse( + "SELECT " + matcher + " EXCEPT STRICT '^tmp_' FROM t"); + Expression expression = select.getSelectItem(0).getExpression(); + ColumnsTransformer transformer = getTransformer(expression); + transformer.setExceptColumns(new ParenthesedExpressionList<>(new Column("keep"))); + + assertOutput("SELECT " + matcher + " EXCEPT STRICT (keep) FROM t", select); + assertNull(transformer.getExceptPattern()); + if (expression instanceof ColumnsExpression) { + assertVisitedValues(List.of("m", "keep"), expression); + } + } + + @ParameterizedTest + @ValueSource(strings = {"*", "t.*", "COLUMNS('m')"}) + void testExceptPatternReplacesColumns(String matcher) throws JSQLParserException { + PlainSelect select = (PlainSelect) CCJSqlParserUtil.parse( + "SELECT " + matcher + " EXCEPT STRICT (keep) FROM t"); + Expression expression = select.getSelectItem(0).getExpression(); + ColumnsTransformer transformer = getTransformer(expression); + transformer.setExceptPattern(new StringValue("'^tmp_'")); + + assertOutput("SELECT " + matcher + " EXCEPT STRICT '^tmp_' FROM t", select); + if (expression instanceof ColumnsExpression) { + assertVisitedValues(List.of("m", "^tmp_"), expression); + } + assertNull(transformer.getExceptColumns()); + } + + @Test + void testClearingPatternPreservesColumns() { + ColumnsTransformer transformer = + new ColumnsTransformer(ColumnsTransformer.ColumnsTransformerType.EXCEPT) + .setExceptColumns(new ParenthesedExpressionList<>(new Column("keep"))) + .setExceptPattern(null); + assertEquals("EXCEPT (keep)", transformer.toString()); + } + + @Test + void testClearingColumnsPreservesPattern() { + ColumnsTransformer transformer = + new ColumnsTransformer(ColumnsTransformer.ColumnsTransformerType.EXCEPT) + .setExceptPattern(new StringValue("'^tmp_'")) + .setExceptColumns(null); + assertEquals("EXCEPT '^tmp_'", transformer.toString()); + } + + private static ColumnsTransformer getTransformer(Expression expression) { + return expression instanceof AllColumns + ? ((AllColumns) expression).getTransformers().get(0) + : ((ColumnsExpression) expression).getTransformers().get(0); + } + + private static void assertOutput(String expected, PlainSelect select) + throws JSQLParserException { + assertEquals(expected, select.toString()); + StatementDeParser deparser = new StatementDeParser(new StringBuilder()); + select.accept(deparser, null); + assertEquals(expected, deparser.getBuilder().toString()); + assertEquals(expected, CCJSqlParserUtil.parse(expected).toString()); + } + + private static void assertVisitedValues(List expected, Expression expression) { + List visited = new ArrayList<>(); + expression.accept(new ExpressionVisitorAdapter() { + @Override + public Void visit(StringValue value, S context) { + visited.add(value.getValue()); + return null; + } + + @Override + public Void visit(Column column, S context) { + visited.add(column.getColumnName()); + return null; + } + }, null); + assertEquals(expected, visited); + } +} diff --git a/src/test/java/net/sf/jsqlparser/statement/select/ClickHouseTest.java b/src/test/java/net/sf/jsqlparser/statement/select/ClickHouseTest.java index 4bce28de6..76b92ad67 100644 --- a/src/test/java/net/sf/jsqlparser/statement/select/ClickHouseTest.java +++ b/src/test/java/net/sf/jsqlparser/statement/select/ClickHouseTest.java @@ -21,9 +21,11 @@ import net.sf.jsqlparser.expression.ExpressionVisitorAdapter; import net.sf.jsqlparser.expression.Function; import net.sf.jsqlparser.expression.LambdaExpression; +import net.sf.jsqlparser.expression.StringValue; import net.sf.jsqlparser.expression.operators.relational.ParenthesedExpressionList; import net.sf.jsqlparser.parser.CCJSqlParserUtil; import net.sf.jsqlparser.util.TablesNamesFinder; +import net.sf.jsqlparser.util.deparser.StatementDeParser; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; @@ -358,6 +360,228 @@ public void testAllColumnsChainedTransformersIssue2636() throws JSQLParserExcept assertSqlCanBeParsedAndDeparsed(sql, true); } + @Test + public void testAllColumnsExceptStrictIssue2636() throws JSQLParserException { + // STRICT sits between the EXCEPT keyword and its column list + // (ClickHouse ExpressionElementParsers.cpp, ParserColumnsTransformers) + String sql = "SELECT * EXCEPT STRICT (a, b) FROM t"; + Select select = (Select) CCJSqlParserUtil.parse(sql); + PlainSelect plainSelect = (PlainSelect) select.getSelectBody(); + SelectItem selectItem = plainSelect.getSelectItems().get(0); + AllColumns allColumns = + Assertions.assertInstanceOf(AllColumns.class, selectItem.getExpression()); + ColumnsTransformer transformer = allColumns.getTransformers().get(0); + Assertions.assertEquals(ColumnsTransformer.ColumnsTransformerType.EXCEPT, + transformer.getType()); + Assertions.assertTrue(transformer.isStrict()); + Assertions.assertInstanceOf(ParenthesedExpressionList.class, + transformer.getExceptColumns()); + assertSqlCanBeParsedAndDeparsed(sql, true); + + // the qualified '*' shares the same transformer production + sql = "SELECT t.* EXCEPT STRICT (a) FROM t"; + select = (Select) CCJSqlParserUtil.parse(sql); + plainSelect = (PlainSelect) select.getSelectBody(); + AllTableColumns allTableColumns = Assertions.assertInstanceOf(AllTableColumns.class, + plainSelect.getSelectItems().get(0).getExpression()); + Assertions.assertTrue(allTableColumns.getTransformers().get(0).isStrict()); + assertSqlCanBeParsedAndDeparsed(sql, true); + + // the flag belongs to one transformer of the chain only + sql = "SELECT * EXCEPT STRICT (a) REPLACE(x AS a) FROM t"; + select = (Select) CCJSqlParserUtil.parse(sql); + plainSelect = (PlainSelect) select.getSelectBody(); + allColumns = Assertions.assertInstanceOf(AllColumns.class, + plainSelect.getSelectItems().get(0).getExpression()); + Assertions.assertEquals(2, allColumns.getTransformers().size()); + Assertions.assertTrue(allColumns.getTransformers().get(0).isStrict()); + Assertions.assertFalse(allColumns.getTransformers().get(1).isStrict()); + assertSqlCanBeParsedAndDeparsed(sql, true); + + // the transformer survives as the second leg of a set operation + sql = "SELECT 1 UNION SELECT * EXCEPT STRICT (a) FROM t"; + SetOperationList union = (SetOperationList) ((Select) CCJSqlParserUtil.parse(sql)) + .getSelectBody(); + PlainSelect rightLeg = (PlainSelect) union.getSelects().get(1); + AllColumns legColumns = Assertions.assertInstanceOf(AllColumns.class, + rightLeg.getSelectItems().get(0).getExpression()); + Assertions.assertTrue(legColumns.getTransformers().get(0).isStrict()); + assertSqlCanBeParsedAndDeparsed(sql, true); + + // the COLUMNS(...) matcher accepts the same STRICT form + sql = "SELECT COLUMNS('m') EXCEPT STRICT (a) FROM t"; + select = (Select) CCJSqlParserUtil.parse(sql); + plainSelect = (PlainSelect) select.getSelectBody(); + ColumnsExpression columnsExpression = Assertions.assertInstanceOf(ColumnsExpression.class, + plainSelect.getSelectItems().get(0).getExpression()); + Assertions.assertTrue(columnsExpression.getTransformers().get(0).isStrict()); + assertSqlCanBeParsedAndDeparsed(sql, true); + } + + @Test + public void testAllColumnsReplaceStrictIssue2636() throws JSQLParserException { + String sql = "SELECT * REPLACE STRICT(1 AS a, 2 AS b) FROM t"; + Select select = (Select) CCJSqlParserUtil.parse(sql); + PlainSelect plainSelect = (PlainSelect) select.getSelectBody(); + AllColumns allColumns = Assertions.assertInstanceOf(AllColumns.class, + plainSelect.getSelectItems().get(0).getExpression()); + ColumnsTransformer transformer = allColumns.getTransformers().get(0); + Assertions.assertEquals(ColumnsTransformer.ColumnsTransformerType.REPLACE, + transformer.getType()); + Assertions.assertTrue(transformer.isStrict()); + Assertions.assertEquals(2, transformer.getReplaceItems().size()); + Assertions.assertEquals("a", transformer.getReplaceItems().get(0).getAlias().getName()); + Assertions.assertEquals(sql, select.toString()); + StatementDeParser statementDeParser = new StatementDeParser(new StringBuilder()); + select.accept(statementDeParser, null); + Assertions.assertEquals(sql, statementDeParser.getBuilder().toString()); + assertSqlCanBeParsedAndDeparsed(sql, true); + + sql = "SELECT COLUMNS('m') REPLACE STRICT(x + 1 AS x) FROM t"; + select = (Select) CCJSqlParserUtil.parse(sql); + plainSelect = (PlainSelect) select.getSelectBody(); + Assertions.assertInstanceOf(ColumnsExpression.class, + plainSelect.getSelectItems().get(0).getExpression()); + assertSqlCanBeParsedAndDeparsed(sql, true); + + // the qualified '*' copies the transformers through its own constructor + sql = "SELECT t.* REPLACE STRICT(x AS x) FROM t"; + select = (Select) CCJSqlParserUtil.parse(sql); + plainSelect = (PlainSelect) select.getSelectBody(); + AllTableColumns qualifiedStar = Assertions.assertInstanceOf(AllTableColumns.class, + plainSelect.getSelectItems().get(0).getExpression()); + Assertions.assertTrue(qualifiedStar.getTransformers().get(0).isStrict()); + assertSqlCanBeParsedAndDeparsed(sql, true); + + // each transformer of the chain carries its own flag + sql = "SELECT * REPLACE STRICT(x AS x) REPLACE(y AS y) FROM t"; + select = (Select) CCJSqlParserUtil.parse(sql); + plainSelect = (PlainSelect) select.getSelectBody(); + allColumns = Assertions.assertInstanceOf(AllColumns.class, + plainSelect.getSelectItems().get(0).getExpression()); + Assertions.assertEquals(2, allColumns.getTransformers().size()); + Assertions.assertTrue(allColumns.getTransformers().get(0).isStrict()); + Assertions.assertFalse(allColumns.getTransformers().get(1).isStrict()); + assertSqlCanBeParsedAndDeparsed(sql, true); + + sql = "SELECT * EXCEPT (a) REPLACE STRICT(x AS a) APPLY(sum) FROM t"; + assertSqlCanBeParsedAndDeparsed(sql, true); + } + + @Test + public void testAllColumnsExceptPatternIssue2636() throws JSQLParserException { + // ClickHouse accepts a re2 pattern string instead of the column list and always + // prints it without brackets (ASTColumnsTransformers.cpp) + String sql = "SELECT * EXCEPT '^tmp_' FROM t"; + Select select = (Select) CCJSqlParserUtil.parse(sql); + PlainSelect plainSelect = (PlainSelect) select.getSelectBody(); + AllColumns allColumns = Assertions.assertInstanceOf(AllColumns.class, + plainSelect.getSelectItems().get(0).getExpression()); + ColumnsTransformer transformer = allColumns.getTransformers().get(0); + Assertions.assertEquals(ColumnsTransformer.ColumnsTransformerType.EXCEPT, + transformer.getType()); + Assertions.assertNull(transformer.getExceptColumns()); + Assertions.assertEquals("^tmp_", transformer.getExceptPattern().getValue()); + assertSqlCanBeParsedAndDeparsed(sql, true); + + sql = "SELECT t.* EXCEPT '^tmp_' FROM t"; + assertSqlCanBeParsedAndDeparsed(sql, true); + + sql = "SELECT COLUMNS('m') EXCEPT '^tmp_' FROM t"; + select = (Select) CCJSqlParserUtil.parse(sql); + plainSelect = (PlainSelect) select.getSelectBody(); + ColumnsExpression columnsExpression = Assertions.assertInstanceOf(ColumnsExpression.class, + plainSelect.getSelectItems().get(0).getExpression()); + Assertions.assertEquals("^tmp_", + columnsExpression.getTransformers().get(0).getExceptPattern().getValue()); + assertSqlCanBeParsedAndDeparsed(sql, true); + + // ClickHouse parses STRICT before a pattern as well (it drops the flag only + // during analysis), so the parser keeps it on the AST + sql = "SELECT * EXCEPT STRICT '^tmp_' FROM t"; + select = (Select) CCJSqlParserUtil.parse(sql); + plainSelect = (PlainSelect) select.getSelectBody(); + allColumns = Assertions.assertInstanceOf(AllColumns.class, + plainSelect.getSelectItems().get(0).getExpression()); + Assertions.assertTrue(allColumns.getTransformers().get(0).isStrict()); + Assertions.assertEquals("^tmp_", + allColumns.getTransformers().get(0).getExceptPattern().getValue()); + assertSqlCanBeParsedAndDeparsed(sql, true); + + // the same combination reaches the COLUMNS(...) path through its own gate + sql = "SELECT COLUMNS('m') EXCEPT STRICT '^tmp2_' FROM t"; + assertSqlCanBeParsedAndDeparsed(sql, true); + + // the pattern participates in the expression traversal of the COLUMNS side + // (getAllExpressions -> ExpressionVisitorAdapter), like the other payloads + List visitedValues = new ArrayList<>(); + ExpressionVisitorAdapter adapter = new ExpressionVisitorAdapter() { + @Override + public Void visit(StringValue stringValue, Object context) { + visitedValues.add(stringValue.getValue()); + return null; + } + }; + select = (Select) CCJSqlParserUtil.parse("SELECT COLUMNS('m') EXCEPT '^tmp_' FROM t"); + plainSelect = (PlainSelect) select.getSelectBody(); + plainSelect.getSelectItems().get(0).getExpression().accept(adapter, null); + Assertions.assertTrue(visitedValues.contains("^tmp_"), + "the EXCEPT pattern must be reachable for expression visitors"); + + // programmatic construction round-trips through the same output paths + AllColumns built = new AllColumns(); + built.addTransformer( + new ColumnsTransformer(ColumnsTransformer.ColumnsTransformerType.EXCEPT) + .setStrict(true).setExceptPattern(new StringValue("'inner_.*'"))); + Assertions.assertEquals("* EXCEPT STRICT 'inner_.*'", built.toString()); + } + + @Test + public void testStrictKeywordStaysIdentifierAndAliasIssue2636() throws JSQLParserException { + // STRICT is a non-reserved keyword, so plain identifier uses must keep parsing + assertSqlCanBeParsedAndDeparsed("SELECT strict FROM t", true); + assertSqlCanBeParsedAndDeparsed("SELECT * AS strict FROM t", true); + assertSqlCanBeParsedAndDeparsed("SELECT t.strict FROM t", true); + assertSqlCanBeParsedAndDeparsed("SELECT * EXCEPT (strict) FROM t", true); + + // a trailing STRICT is not part of a transformer: it stays the alias of '*' + String sql = "SELECT * REPLACE(x AS y) STRICT FROM t"; + Select select = (Select) CCJSqlParserUtil.parse(sql); + PlainSelect plainSelect = (PlainSelect) select.getSelectBody(); + SelectItem selectItem = plainSelect.getSelectItems().get(0); + Assertions.assertEquals("STRICT", selectItem.getAlias().getName()); + Assertions.assertInstanceOf(AllColumns.class, selectItem.getExpression()); + assertSqlCanBeParsedAndDeparsed(sql, true); + + // a column named strict and a trailing STRICT alias coexist on one item + assertSqlCanBeParsedAndDeparsed("SELECT * EXCEPT (strict) STRICT FROM t", true); + + // the parenthesized set operation after '*' is unchanged + assertSqlCanBeParsedAndDeparsed("SELECT * EXCEPT (SELECT b FROM u)", true); + } + + @Test + public void testStrictTransformerFormsRejectedIssue2636() { + assertThrowsParseException("SELECT * EXCEPT STRICT FROM t"); + assertThrowsParseException("SELECT * REPLACE STRICT FROM t"); + assertThrowsParseException("SELECT * EXCEPT STRICT a FROM t"); + assertThrowsParseException("SELECT * EXCEPT ('^tmp_') FROM t"); + assertThrowsParseException("SELECT * EXCEPT 5 FROM t"); + // neither APPLY nor EXCLUDE has a STRICT variant + assertThrowsParseException("SELECT * APPLY STRICT (x) FROM t"); + assertThrowsParseException("SELECT * EXCLUDE STRICT (a) FROM t"); + assertThrowsParseException("SELECT COLUMNS('m') EXCEPT STRICT a FROM t"); + assertThrowsParseException("SELECT a EXCEPT STRICT FROM t"); + // the pattern and the parenthesized column list are mutually exclusive payloads + assertThrowsParseException("SELECT * EXCEPT STRICT '^x' (a) FROM t"); + assertThrowsParseException("SELECT * REPLACE STRICT () FROM t"); + } + + private static void assertThrowsParseException(String sql) { + Assertions.assertThrows(JSQLParserException.class, () -> CCJSqlParserUtil.parse(sql), + "Expected a parse failure for: " + sql); + } + @Test public void testColumnsApplyMultiParamLambda() throws JSQLParserException { String sql = "SELECT COLUMNS('m') APPLY((k, v) -> v > 5) FROM metrics";