diff --git a/.codacy.yml b/.codacy.yml index 772fed89d..cf0315d76 100644 --- a/.codacy.yml +++ b/.codacy.yml @@ -4,5 +4,8 @@ engines: exclude_paths: # These fixtures use PostgreSQL grammar, not Transact-SQL. - "src/test/resources/postgresql/**" + # MATCH_RECOGNIZE fixtures use Oracle and BigQuery grammar. + - "src/test/resources/net/sf/jsqlparser/statement/select/match-recognize/**" + - "src/test/resources/net/sf/jsqlparser/statement/select/match-recognize-2350.sql" exclude_paths: - "site/**" diff --git a/src/main/java/net/sf/jsqlparser/expression/ExpressionVisitor.java b/src/main/java/net/sf/jsqlparser/expression/ExpressionVisitor.java index 9d20144c9..a1e7cbac6 100644 --- a/src/main/java/net/sf/jsqlparser/expression/ExpressionVisitor.java +++ b/src/main/java/net/sf/jsqlparser/expression/ExpressionVisitor.java @@ -510,6 +510,14 @@ default void visit(ExtractExpression extractExpression) { this.visit(extractExpression, null); } + default T visit(RowPatternFunction function, S context) { + return function.getFunction().accept(this, context); + } + + default void visit(RowPatternFunction function) { + visit(function, null); + } + T visit(IntervalExpression intervalExpression, S context); default void visit(IntervalExpression intervalExpression) { diff --git a/src/main/java/net/sf/jsqlparser/expression/RowPatternFunction.java b/src/main/java/net/sf/jsqlparser/expression/RowPatternFunction.java new file mode 100644 index 000000000..a6ee2cd98 --- /dev/null +++ b/src/main/java/net/sf/jsqlparser/expression/RowPatternFunction.java @@ -0,0 +1,63 @@ +/*- + * #%L + * JSQLParser library + * %% + * Copyright (C) 2004 - 2026 JSQLParser + * %% + * Dual licensed under GNU LGPL 2.1 or Apache License 2.0 + * #L% + */ +package net.sf.jsqlparser.expression; + +import java.util.function.Consumer; +import net.sf.jsqlparser.parser.ASTNodeAccessImpl; + +/** An explicit RUNNING or FINAL evaluation mode on a row-pattern function. */ +public class RowPatternFunction extends ASTNodeAccessImpl implements Expression { + public enum EvaluationMode { + RUNNING, FINAL + } + + private EvaluationMode evaluationMode; + private Function function; + + public RowPatternFunction(EvaluationMode evaluationMode, Function function) { + this.evaluationMode = evaluationMode; + this.function = function; + } + + public EvaluationMode getEvaluationMode() { + return evaluationMode; + } + + public RowPatternFunction setEvaluationMode(EvaluationMode mode) { + evaluationMode = mode; + return this; + } + + public Function getFunction() { + return function; + } + + public RowPatternFunction setFunction(Function function) { + this.function = function; + return this; + } + + @Override + public T accept(ExpressionVisitor visitor, S context) { + return visitor.visit(this, context); + } + + public StringBuilder appendTo(StringBuilder builder, Consumer expressions) { + builder.append(evaluationMode).append(' '); + expressions.accept(function); + return builder; + } + + @Override + public String toString() { + StringBuilder builder = new StringBuilder(); + return appendTo(builder, expression -> builder.append(expression)).toString(); + } +} diff --git a/src/main/java/net/sf/jsqlparser/expression/StructType.java b/src/main/java/net/sf/jsqlparser/expression/StructType.java index 4bd38a693..c2b59c51a 100644 --- a/src/main/java/net/sf/jsqlparser/expression/StructType.java +++ b/src/main/java/net/sf/jsqlparser/expression/StructType.java @@ -16,6 +16,7 @@ import java.util.ArrayList; import java.util.List; import java.util.Map; +import java.util.function.Consumer; /* * STRUCT @@ -113,73 +114,62 @@ public StructType add(Expression expression, String aliasName) { } public StringBuilder appendTo(StringBuilder builder) { - if (dialect != Dialect.DUCKDB && keyword != null) { - builder.append(keyword); - } + return appendTo(builder, expression -> builder.append(expression)); + } - if (dialect != Dialect.DUCKDB && parameters != null && !parameters.isEmpty()) { - builder.append("<"); - int i = 0; - - for (Map.Entry e : parameters) { - if (0 < i++) { - builder.append(","); - } - // optional name - if (e.getKey() != null && !e.getKey().isEmpty()) { - builder.append(e.getKey()).append(" "); - } - - // mandatory type - builder.append(e.getValue()); + /** Renders current argument expressions through the caller's visitor. */ + public StringBuilder appendTo(StringBuilder builder, Consumer expressions) { + if (dialect == Dialect.DUCKDB) { + appendArguments(builder, expressions); + appendTypeParameters(builder, true); + } else { + if (keyword != null) { + builder.append(keyword); } - - builder.append(">"); + appendTypeParameters(builder, false); + appendArguments(builder, expressions); } + return builder; + } - if (arguments != null && !arguments.isEmpty()) { - - if (dialect == Dialect.DUCKDB) { - builder.append("{ "); - int i = 0; - for (SelectItem e : arguments) { - if (0 < i++) { - builder.append(","); - } - builder.append(e.getAlias().getName()); - builder.append(":"); - builder.append(e.getExpression()); - } - builder.append(" }"); - } else { - builder.append("("); - int i = 0; - for (SelectItem e : arguments) { - if (0 < i++) { - builder.append(","); - } - e.appendTo(builder); - } - - builder.append(")"); + private void appendTypeParameters(StringBuilder builder, boolean cast) { + if (parameters == null || parameters.isEmpty()) { + return; + } + builder.append(cast ? "::STRUCT( " : "<"); + int i = 0; + for (Map.Entry parameter : parameters) { + if (i++ > 0) { + builder.append(','); + } + if (cast || parameter.getKey() != null && !parameter.getKey().isEmpty()) { + builder.append(parameter.getKey()).append(' '); } + builder.append(parameter.getValue()); } + builder.append(cast ? ')' : '>'); + } - if (dialect == Dialect.DUCKDB && parameters != null && !parameters.isEmpty()) { - builder.append("::STRUCT( "); - int i = 0; - - for (Map.Entry e : parameters) { - if (0 < i++) { - builder.append(","); - } - builder.append(e.getKey()).append(" "); - builder.append(e.getValue()); + private void appendArguments(StringBuilder builder, Consumer expressions) { + if (arguments == null || arguments.isEmpty()) { + return; + } + boolean structLiteral = dialect == Dialect.DUCKDB; + builder.append(structLiteral ? "{ " : "("); + int i = 0; + for (SelectItem argument : arguments) { + if (i++ > 0) { + builder.append(','); + } + if (structLiteral) { + builder.append(argument.getAlias().getName()).append(':'); + } + expressions.accept(argument.getExpression()); + if (!structLiteral && argument.getAlias() != null) { + builder.append(argument.getAlias()); } - builder.append(")"); } - - return builder; + builder.append(structLiteral ? " }" : ")"); } @Override diff --git a/src/main/java/net/sf/jsqlparser/parser/RowPatternTokenContext.java b/src/main/java/net/sf/jsqlparser/parser/RowPatternTokenContext.java new file mode 100644 index 000000000..f10b7c12b --- /dev/null +++ b/src/main/java/net/sf/jsqlparser/parser/RowPatternTokenContext.java @@ -0,0 +1,83 @@ +/*- + * #%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.parser; + +import java.util.ArrayDeque; +import java.util.Deque; + +/** + * Recognizes the BigQuery PATTERN lexical region even during JavaCC lookahead. The required initial + * ORDER BY (or optional PARTITION BY) distinguishes a clause from an ordinary function named + * MATCH_RECOGNIZE. Quoted tokens never change this state. + */ +final class RowPatternTokenContext implements CCJSqlParserConstants { + private final Deque frames = new ArrayDeque<>(); + private int depth; + private boolean pendingClause; + + private static final class Frame { + private final int depth; + private int phase; + private int patternDepth = -1; + private boolean pendingPattern; + private boolean patternComplete; + + private Frame(int depth) { + this.depth = depth; + } + } + + boolean isIdle() { + return frames.isEmpty() && !pendingClause; + } + + boolean isInPattern() { + return !frames.isEmpty() && frames.peek().patternDepth >= 0; + } + + void accept(Token token) { + if (token.kind == OPENING_BRACKET) { + depth++; + if (pendingClause) { + frames.push(new Frame(depth)); + pendingClause = false; + return; + } + if (!frames.isEmpty() && frames.peek().pendingPattern) { + frames.peek().patternDepth = depth; + frames.peek().pendingPattern = false; + } + } else if (token.kind == CLOSING_BRACKET) { + if (!frames.isEmpty()) { + Frame frame = frames.peek(); + if (frame.patternDepth == depth) { + frame.patternDepth = -1; + frame.patternComplete = true; + } + if (frame.depth == depth) { + frames.pop(); + } + } + depth--; + } else if (!frames.isEmpty() && depth == frames.peek().depth) { + Frame frame = frames.peek(); + if (frame.phase == 0) { + frame.phase = token.kind == K_ORDER || token.kind == K_PARTITION ? 1 : -1; + } else if (frame.phase == 1) { + frame.phase = token.kind == K_BY ? 2 : -1; + } else if (frame.phase == 2 && !frame.patternComplete) { + frame.pendingPattern = token.kind == S_IDENTIFIER + && "PATTERN".equalsIgnoreCase(token.image); + } + } + pendingClause = token.kind == S_IDENTIFIER + && "MATCH_RECOGNIZE".equalsIgnoreCase(token.image); + } +} diff --git a/src/main/java/net/sf/jsqlparser/parser/feature/Feature.java b/src/main/java/net/sf/jsqlparser/parser/feature/Feature.java index 21ae94db5..9c895e359 100644 --- a/src/main/java/net/sf/jsqlparser/parser/feature/Feature.java +++ b/src/main/java/net/sf/jsqlparser/parser/feature/Feature.java @@ -71,6 +71,10 @@ public enum Feature { * "SELECT" */ select, + /** Structured FROM row pattern recognition. */ + matchRecognize, + /** BigQuery longest-match option. */ + matchRecognizeOptions, /** * "GROUP BY" */ diff --git a/src/main/java/net/sf/jsqlparser/statement/select/FromItemVisitor.java b/src/main/java/net/sf/jsqlparser/statement/select/FromItemVisitor.java index 69af24f55..39abfbd24 100644 --- a/src/main/java/net/sf/jsqlparser/statement/select/FromItemVisitor.java +++ b/src/main/java/net/sf/jsqlparser/statement/select/FromItemVisitor.java @@ -42,6 +42,18 @@ default T visitJoins(Collection joins, S context) { return null; } + /** + * Existing visitors continue through the input relation; adapters also visit clause + * expressions. + */ + default T visit(MatchRecognize matchRecognize, S context) { + return matchRecognize.getInput().accept(this, context); + } + + default void visit(MatchRecognize matchRecognize) { + visit(matchRecognize, null); + } + T visit(Table tableName, S context); default void visit(Table tableName) { diff --git a/src/main/java/net/sf/jsqlparser/statement/select/FromItemVisitorAdapter.java b/src/main/java/net/sf/jsqlparser/statement/select/FromItemVisitorAdapter.java index 3c88d63c4..b40545499 100644 --- a/src/main/java/net/sf/jsqlparser/statement/select/FromItemVisitorAdapter.java +++ b/src/main/java/net/sf/jsqlparser/statement/select/FromItemVisitorAdapter.java @@ -85,6 +85,21 @@ public T visitJoins(Collection joins, S context) { return null; } + @Override + public T visit(MatchRecognize matchRecognize, S context) { + matchRecognize.getInput().accept(this, context); + matchRecognize + .forEachExpression(expression -> expression.accept(expressionVisitor, context)); + PivotVisitorAdapter pivots = new PivotVisitorAdapter<>(expressionVisitor); + if (matchRecognize.getPivot() != null) { + matchRecognize.getPivot().accept(pivots, context); + } + if (matchRecognize.getUnPivot() != null) { + matchRecognize.getUnPivot().accept(pivots, context); + } + return null; + } + @Override public T visit(Table table, S context) { return null; @@ -92,12 +107,12 @@ public T visit(Table table, S context) { @Override public T visit(ParenthesedSelect select, S context) { - return select.getPlainSelect().accept(selectVisitor, context); + return select.getSelect().accept(selectVisitor, context); } @Override public T visit(LateralSubSelect lateralSubSelect, S context) { - return lateralSubSelect.getPlainSelect().accept(selectVisitor, context); + return lateralSubSelect.getSelect().accept(selectVisitor, context); } @Override @@ -108,7 +123,9 @@ public T visit(TableFunction tableFunction, S context) { @Override public T visit(ParenthesedFromItem fromItem, S context) { - return fromItem.getFromItem().accept(this, context); + T result = fromItem.getFromItem().accept(this, context); + visitJoins(fromItem.getJoins(), context); + return result; } @Override diff --git a/src/main/java/net/sf/jsqlparser/statement/select/MatchRecognize.java b/src/main/java/net/sf/jsqlparser/statement/select/MatchRecognize.java new file mode 100644 index 000000000..1744277a8 --- /dev/null +++ b/src/main/java/net/sf/jsqlparser/statement/select/MatchRecognize.java @@ -0,0 +1,418 @@ +/*- + * #%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.util.ArrayList; +import java.util.List; +import java.util.Set; +import java.util.HashSet; +import java.util.Locale; +import net.sf.jsqlparser.schema.MultiPartName; +import java.util.function.Consumer; +import net.sf.jsqlparser.expression.Expression; +import net.sf.jsqlparser.expression.operators.relational.ExpressionList; +import net.sf.jsqlparser.parser.ASTNodeAccessImpl; + +/** A relation transformed by MATCH_RECOGNIZE. Input and result aliases are independent. */ +public class MatchRecognize extends AbstractFromitem { + public enum SkipMode { + PAST_LAST_ROW, TO_NEXT_ROW, TO_FIRST, TO_LAST, TO_VARIABLE + } + + public enum RowsPerMatch { + ONE, ALL + } + + public enum EmptyMatchMode { + SHOW, OMIT, WITH_UNMATCHED + } + + private RowsPerMatch rowsPerMatch; + private EmptyMatchMode emptyMatchMode; + private String skipVariable; + private List subsets = new ArrayList<>(); + private FromItem input; + private ExpressionList partitionBy = new ExpressionList<>(); + private List orderByElements = new ArrayList<>(); + private List> measures = new ArrayList<>(); + private SkipMode skipMode; + private RowPattern pattern; + private List definitions = new ArrayList<>(); + private Options options; + + public MatchRecognize(FromItem input) { + this.input = input; + } + + public FromItem getInput() { + return input; + } + + public MatchRecognize setInput(FromItem input) { + this.input = input; + return this; + } + + public ExpressionList getPartitionBy() { + return partitionBy; + } + + public MatchRecognize setPartitionBy(ExpressionList partitionBy) { + this.partitionBy = partitionBy; + return this; + } + + public List getOrderByElements() { + return orderByElements; + } + + public MatchRecognize setOrderByElements(List elements) { + orderByElements = elements; + return this; + } + + public List> getMeasures() { + return measures; + } + + public MatchRecognize setMeasures(List> measures) { + this.measures = measures; + return this; + } + + public SkipMode getSkipMode() { + return skipMode; + } + + public MatchRecognize setSkipMode(SkipMode mode) { + skipMode = mode; + return this; + } + + public RowsPerMatch getRowsPerMatch() { + return rowsPerMatch; + } + + public MatchRecognize setRowsPerMatch(RowsPerMatch value) { + rowsPerMatch = value; + return this; + } + + public EmptyMatchMode getEmptyMatchMode() { + return emptyMatchMode; + } + + public MatchRecognize setEmptyMatchMode(EmptyMatchMode value) { + emptyMatchMode = value; + return this; + } + + public String getSkipVariable() { + return skipVariable; + } + + public MatchRecognize setSkipVariable(String value) { + skipVariable = value; + return this; + } + + public List getSubsets() { + return subsets; + } + + public MatchRecognize setSubsets(List value) { + subsets = value; + return this; + } + + public RowPattern getPattern() { + return pattern; + } + + public MatchRecognize setPattern(RowPattern pattern) { + this.pattern = pattern; + return this; + } + + public List getDefinitions() { + return definitions; + } + + public MatchRecognize setDefinitions(List definitions) { + this.definitions = definitions; + return this; + } + + public Options getOptions() { + return options; + } + + public MatchRecognize setOptions(Options options) { + this.options = options; + return this; + } + + @Override + public T accept(FromItemVisitor visitor, S context) { + return visitor.visit(this, context); + } + + /** Case-insensitive pattern names for syntactic table discovery, including SUBSET names. */ + public Set getPatternVariableNames() { + Set names = new HashSet<>(); + if (pattern != null) { + pattern.accept(new RowPatternVisitorAdapter() { + @Override + public Void visit(RowPattern.Variable variable, S context) { + names.add(normalizeVariableName(variable.getName())); + return null; + } + }, null); + } + subsets.forEach(subset -> names.add(normalizeVariableName(subset.getName()))); + return names; + } + + public static String normalizeVariableName(String name) { + return name == null ? null : MultiPartName.unquote(name).toLowerCase(Locale.ROOT); + } + + /** Visits expression roots once; normal expression visitors traverse their descendants. */ + public void forEachExpression(Consumer expressions) { + partitionBy.forEach(expressions); + orderByElements.forEach(order -> expressions.accept(order.getExpression())); + forEachPatternExpression(expressions); + } + + /** Expression roots where pattern variables, rather than input aliases, are in scope. */ + public void forEachPatternExpression(Consumer expressions) { + measures.forEach(measure -> expressions.accept(measure.getExpression())); + pattern.forEachExpression(expressions); + definitions.forEach(definition -> expressions.accept(definition.getExpression())); + } + + /** Shared clause rendering, retaining the caller's source and expression customization. */ + public StringBuilder appendTo(StringBuilder builder, Consumer sources, + Consumer expressions, Consumer ordering, + Consumer pivots, Consumer unpivots) { + sources.accept(input); + builder.append(" MATCH_RECOGNIZE ("); + appendInputExpressions(builder, expressions, ordering); + appendRowOutput(builder); + appendSkip(builder); + builder.append("PATTERN ("); + pattern.appendTo(builder, expressions); + builder.append(')'); + appendSubsets(builder); + builder.append(" DEFINE "); + appendList(builder, definitions, definition -> { + builder.append(definition.getName()).append(" AS "); + expressions.accept(definition.getExpression()); + }); + appendOptions(builder); + builder.append(')'); + appendResultModifiers(builder, pivots, unpivots); + return builder; + } + + private void appendInputExpressions(StringBuilder builder, Consumer expressions, + Consumer ordering) { + if (!partitionBy.isEmpty()) { + builder.append("PARTITION BY "); + appendList(builder, partitionBy, expressions); + builder.append(' '); + } + if (!orderByElements.isEmpty()) { + builder.append("ORDER BY "); + appendList(builder, orderByElements, ordering); + builder.append(' '); + } + if (!measures.isEmpty()) { + builder.append("MEASURES "); + appendList(builder, measures, item -> { + expressions.accept(item.getExpression()); + if (item.getAlias() != null) { + builder.append(item.getAlias()); + } + }); + builder.append(' '); + } + } + + private void appendRowOutput(StringBuilder builder) { + if (rowsPerMatch != null) { + builder.append(rowsPerMatch == RowsPerMatch.ONE ? "ONE ROW PER MATCH " + : "ALL ROWS PER MATCH "); + } + if (emptyMatchMode != null) { + switch (emptyMatchMode) { + case SHOW: + builder.append("SHOW EMPTY MATCHES "); + break; + case OMIT: + builder.append("OMIT EMPTY MATCHES "); + break; + default: + builder.append("WITH UNMATCHED ROWS "); + } + } + } + + private void appendSkip(StringBuilder builder) { + if (skipMode != null) { + builder.append("AFTER MATCH SKIP "); + switch (skipMode) { + case PAST_LAST_ROW: + builder.append("PAST LAST ROW"); + break; + case TO_NEXT_ROW: + builder.append("TO NEXT ROW"); + break; + case TO_FIRST: + builder.append("TO FIRST ").append(skipVariable); + break; + case TO_LAST: + builder.append("TO LAST ").append(skipVariable); + break; + default: + builder.append("TO ").append(skipVariable); + } + builder.append(' '); + } + } + + private void appendSubsets(StringBuilder builder) { + if (!subsets.isEmpty()) { + builder.append(" SUBSET "); + appendList(builder, subsets, subset -> { + builder.append(subset.getName()).append(" = ("); + appendList(builder, subset.getVariables(), name -> builder.append(name)); + builder.append(')'); + }); + } + } + + private void appendOptions(StringBuilder builder) { + if (options != null) { + builder.append(" OPTIONS ("); + if (options.getUseLongestMatch() != null) { + builder.append("use_longest_match = ") + .append(options.getUseLongestMatch() ? "TRUE" : "FALSE"); + } + builder.append(')'); + } + } + + private void appendResultModifiers(StringBuilder builder, Consumer pivots, + Consumer unpivots) { + if (getPivot() != null) { + pivots.accept(getPivot()); + } + if (getUnPivot() != null) { + unpivots.accept(getUnPivot()); + } + if (getAlias() != null) { + builder.append(getAlias()); + } + if (getSampleClause() != null) { + builder.append(getSampleClause()); + } + } + + private static void appendList(StringBuilder builder, List elements, + Consumer append) { + for (int i = 0; i < elements.size(); i++) { + if (i > 0) { + builder.append(", "); + } + append.accept(elements.get(i)); + } + } + + @Override + public String toString() { + StringBuilder builder = new StringBuilder(); + return appendTo(builder, source -> builder.append(source), + expression -> builder.append(expression), order -> builder.append(order), + pivot -> builder.append(" ").append(pivot), + unpivot -> builder.append(" ").append(unpivot)) + .toString(); + } + + public static class Subset extends ASTNodeAccessImpl { + private String name; + private List variables; + + public Subset(String name, List variables) { + this.name = name; + this.variables = variables; + } + + public String getName() { + return name; + } + + public Subset setName(String name) { + this.name = name; + return this; + } + + public List getVariables() { + return variables; + } + + public Subset setVariables(List variables) { + this.variables = variables; + return this; + } + } + + public static class Definition extends ASTNodeAccessImpl { + private String name; + private Expression expression; + + public Definition(String name, Expression expression) { + this.name = name; + this.expression = expression; + } + + public String getName() { + return name; + } + + public Definition setName(String name) { + this.name = name; + return this; + } + + public Expression getExpression() { + return expression; + } + + public Definition setExpression(Expression expression) { + this.expression = expression; + return this; + } + } + + /** The presence of this object distinguishes an empty OPTIONS clause from an omitted one. */ + public static class Options extends ASTNodeAccessImpl { + private Boolean useLongestMatch; + + public Boolean getUseLongestMatch() { + return useLongestMatch; + } + + public Options setUseLongestMatch(Boolean value) { + useLongestMatch = value; + return this; + } + } +} diff --git a/src/main/java/net/sf/jsqlparser/statement/select/PivotVisitorAdapter.java b/src/main/java/net/sf/jsqlparser/statement/select/PivotVisitorAdapter.java index 2a9f2c6af..a8c104792 100644 --- a/src/main/java/net/sf/jsqlparser/statement/select/PivotVisitorAdapter.java +++ b/src/main/java/net/sf/jsqlparser/statement/select/PivotVisitorAdapter.java @@ -26,19 +26,46 @@ public PivotVisitorAdapter(ExpressionVisitor expressionVisitor) { @Override public T visit(Pivot pivot, S context) { - + if (pivot.getFunctionItems() != null) { + pivot.getFunctionItems() + .forEach(item -> item.getExpression().accept(expressionVisitor, context)); + } + if (pivot.getForColumns() != null) { + pivot.getForColumns().accept(expressionVisitor, context); + } + if (pivot.getSingleInItems() != null) { + pivot.getSingleInItems() + .forEach(item -> item.getExpression().accept(expressionVisitor, context)); + } + if (pivot.getMultiInItems() != null) { + pivot.getMultiInItems() + .forEach(item -> item.getExpression().accept(expressionVisitor, context)); + } return null; } @Override public T visit(PivotXml pivot, S context) { - + visit((Pivot) pivot, context); + if (pivot.getInSelect() != null) { + pivot.getInSelect().accept(expressionVisitor, context); + } return null; } @Override public T visit(UnPivot unpivot, S context) { - + if (unpivot.getUnPivotClause() != null) { + unpivot.getUnPivotClause().forEach(column -> column.accept(expressionVisitor, context)); + } + if (unpivot.getUnPivotForClause() != null) { + unpivot.getUnPivotForClause() + .forEach(column -> column.accept(expressionVisitor, context)); + } + if (unpivot.getUnPivotInClause() != null) { + unpivot.getUnPivotInClause() + .forEach(item -> item.getExpression().accept(expressionVisitor, context)); + } return null; } } diff --git a/src/main/java/net/sf/jsqlparser/statement/select/RowPattern.java b/src/main/java/net/sf/jsqlparser/statement/select/RowPattern.java new file mode 100644 index 000000000..9d922ff5b --- /dev/null +++ b/src/main/java/net/sf/jsqlparser/statement/select/RowPattern.java @@ -0,0 +1,392 @@ +/*- + * #%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.util.ArrayList; +import java.util.List; +import java.util.function.Consumer; +import net.sf.jsqlparser.expression.Expression; +import net.sf.jsqlparser.parser.ASTNodeAccessImpl; + +/** A row pattern, with explicit grouping and ordered alternatives preserved. */ +public abstract class RowPattern extends ASTNodeAccessImpl { + public abstract T accept(RowPatternVisitor visitor, S context); + + public abstract StringBuilder appendTo(StringBuilder builder, Consumer expressions); + + /** Visits only SQL expressions embedded in the pattern, such as quantifier bounds. */ + public void forEachExpression(Consumer expressions) {} + + @Override + public String toString() { + StringBuilder builder = new StringBuilder(); + return appendTo(builder, expression -> builder.append(expression)).toString(); + } + + public static class Variable extends RowPattern { + private String name; + + public Variable(String name) { + this.name = name; + } + + public String getName() { + return name; + } + + public Variable setName(String name) { + this.name = name; + return this; + } + + @Override + public T accept(RowPatternVisitor visitor, S context) { + return visitor.visit(this, context); + } + + @Override + public StringBuilder appendTo(StringBuilder builder, Consumer expressions) { + return builder.append(name); + } + } + + public static class Empty extends RowPattern { + @Override + public T accept(RowPatternVisitor visitor, S context) { + return visitor.visit(this, context); + } + + @Override + public StringBuilder appendTo(StringBuilder builder, Consumer expressions) { + return builder; + } + } + + public static class Anchor extends RowPattern { + public enum Type { + START, END + } + + private Type type; + + public Anchor(Type type) { + this.type = type; + } + + public Type getType() { + return type; + } + + public Anchor setType(Type type) { + this.type = type; + return this; + } + + @Override + public T accept(RowPatternVisitor visitor, S context) { + return visitor.visit(this, context); + } + + @Override + public StringBuilder appendTo(StringBuilder builder, Consumer expressions) { + return builder.append(type == Type.START ? '^' : '$'); + } + } + + public static class Group extends RowPattern { + private RowPattern pattern; + + public Group(RowPattern pattern) { + this.pattern = pattern; + } + + public RowPattern getPattern() { + return pattern; + } + + public Group setPattern(RowPattern pattern) { + this.pattern = pattern; + return this; + } + + @Override + public T accept(RowPatternVisitor visitor, S context) { + return visitor.visit(this, context); + } + + @Override + public void forEachExpression(Consumer expressions) { + pattern.forEachExpression(expressions); + } + + @Override + public StringBuilder appendTo(StringBuilder builder, Consumer expressions) { + builder.append('('); + pattern.appendTo(builder, expressions); + return builder.append(')'); + } + } + + public static class Operation extends RowPattern { + public enum Type { + SEQUENCE, ALTERNATION + } + + private Type type; + private List patterns; + + public Operation(Type type, List patterns) { + this.type = type; + this.patterns = new ArrayList<>(patterns); + } + + public Type getType() { + return type; + } + + public Operation setType(Type type) { + this.type = type; + return this; + } + + public List getPatterns() { + return patterns; + } + + public Operation setPatterns(List patterns) { + this.patterns = patterns; + return this; + } + + @Override + public T accept(RowPatternVisitor visitor, S context) { + return visitor.visit(this, context); + } + + @Override + public void forEachExpression(Consumer expressions) { + patterns.forEach(pattern -> pattern.forEachExpression(expressions)); + } + + @Override + public StringBuilder appendTo(StringBuilder builder, Consumer expressions) { + for (int i = 0; i < patterns.size(); i++) { + if (i > 0) { + builder.append(type == Type.SEQUENCE ? " " : " | "); + } + RowPattern child = patterns.get(i); + // Explicit parentheses keep the tree unambiguous across dialects, including + // Snowflake's documented alternative-before-concatenation precedence. + boolean brackets = type == Type.SEQUENCE && child instanceof Empty + || child instanceof Operation && ((Operation) child).type != type; + if (brackets) { + builder.append('('); + } + child.appendTo(builder, expressions); + if (brackets) { + builder.append(')'); + } + } + return builder; + } + } + + /** A permutation stays compact; it is never expanded into factorially many alternatives. */ + public static class Permute extends RowPattern { + private List patterns; + + public Permute(List patterns) { + this.patterns = new ArrayList<>(patterns); + } + + public List getPatterns() { + return patterns; + } + + public Permute setPatterns(List patterns) { + this.patterns = patterns; + return this; + } + + @Override + public T accept(RowPatternVisitor visitor, S context) { + return visitor.visit(this, context); + } + + @Override + public void forEachExpression(Consumer expressions) { + patterns.forEach(pattern -> pattern.forEachExpression(expressions)); + } + + @Override + public StringBuilder appendTo(StringBuilder builder, Consumer expressions) { + builder.append("PERMUTE("); + for (int i = 0; i < patterns.size(); i++) { + if (i > 0) { + builder.append(", "); + } + patterns.get(i).appendTo(builder, expressions); + } + return builder.append(')'); + } + } + + public static class Exclusion extends RowPattern { + private RowPattern pattern; + + public Exclusion(RowPattern pattern) { + this.pattern = pattern; + } + + public RowPattern getPattern() { + return pattern; + } + + public Exclusion setPattern(RowPattern pattern) { + this.pattern = pattern; + return this; + } + + @Override + public T accept(RowPatternVisitor visitor, S context) { + return visitor.visit(this, context); + } + + @Override + public void forEachExpression(Consumer expressions) { + pattern.forEachExpression(expressions); + } + + @Override + public StringBuilder appendTo(StringBuilder builder, Consumer expressions) { + builder.append("{- "); + pattern.appendTo(builder, expressions); + return builder.append(" -}"); + } + } + + public static class Quantified extends RowPattern { + public enum Type { + ZERO_OR_MORE, ONE_OR_MORE, OPTIONAL, EXACT, RANGE + } + + private RowPattern pattern; + private Type type; + private Expression lowerBound; + private Expression upperBound; + private boolean reluctant; + + public Quantified(RowPattern pattern, Type type) { + this.pattern = pattern; + this.type = type; + } + + public RowPattern getPattern() { + return pattern; + } + + public Quantified setPattern(RowPattern pattern) { + this.pattern = pattern; + return this; + } + + public Type getType() { + return type; + } + + public Quantified setType(Type type) { + this.type = type; + return this; + } + + public Expression getLowerBound() { + return lowerBound; + } + + public Quantified setLowerBound(Expression bound) { + lowerBound = bound; + return this; + } + + public Expression getUpperBound() { + return upperBound; + } + + public Quantified setUpperBound(Expression bound) { + upperBound = bound; + return this; + } + + public boolean isReluctant() { + return reluctant; + } + + public Quantified setReluctant(boolean reluctant) { + this.reluctant = reluctant; + return this; + } + + @Override + public T accept(RowPatternVisitor visitor, S context) { + return visitor.visit(this, context); + } + + @Override + public void forEachExpression(Consumer expressions) { + pattern.forEachExpression(expressions); + if (lowerBound != null) { + expressions.accept(lowerBound); + } + if (upperBound != null) { + expressions.accept(upperBound); + } + } + + @Override + public StringBuilder appendTo(StringBuilder builder, Consumer expressions) { + boolean brackets = !(pattern instanceof Variable || pattern instanceof Group + || pattern instanceof Permute || pattern instanceof Exclusion); + if (brackets) { + builder.append('('); + } + pattern.appendTo(builder, expressions); + if (brackets) { + builder.append(')'); + } + switch (type) { + case ZERO_OR_MORE: + builder.append('*'); + break; + case ONE_OR_MORE: + builder.append('+'); + break; + case OPTIONAL: + builder.append('?'); + break; + default: + builder.append('{'); + if (lowerBound != null) { + expressions.accept(lowerBound); + } + if (type == Type.RANGE) { + builder.append(','); + if (upperBound != null) { + expressions.accept(upperBound); + } + } + builder.append('}'); + } + if (reluctant) { + builder.append('?'); + } + return builder; + } + } +} diff --git a/src/main/java/net/sf/jsqlparser/statement/select/RowPatternVisitor.java b/src/main/java/net/sf/jsqlparser/statement/select/RowPatternVisitor.java new file mode 100644 index 000000000..e61bf4ad9 --- /dev/null +++ b/src/main/java/net/sf/jsqlparser/statement/select/RowPatternVisitor.java @@ -0,0 +1,29 @@ +/*- + * #%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; + +/** Visitor for the structural operators of a row pattern. */ +public interface RowPatternVisitor { + T visit(RowPattern.Variable pattern, S context); + + T visit(RowPattern.Empty pattern, S context); + + T visit(RowPattern.Anchor pattern, S context); + + T visit(RowPattern.Group pattern, S context); + + T visit(RowPattern.Operation pattern, S context); + + T visit(RowPattern.Quantified pattern, S context); + + T visit(RowPattern.Permute pattern, S context); + + T visit(RowPattern.Exclusion pattern, S context); +} diff --git a/src/main/java/net/sf/jsqlparser/statement/select/RowPatternVisitorAdapter.java b/src/main/java/net/sf/jsqlparser/statement/select/RowPatternVisitorAdapter.java new file mode 100644 index 000000000..65241f984 --- /dev/null +++ b/src/main/java/net/sf/jsqlparser/statement/select/RowPatternVisitorAdapter.java @@ -0,0 +1,80 @@ +/*- + * #%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 net.sf.jsqlparser.expression.ExpressionVisitor; + +/** Traverses pattern children and, when supplied, SQL expressions in quantifier bounds. */ +public class RowPatternVisitorAdapter implements RowPatternVisitor { + private final ExpressionVisitor expressionVisitor; + + public RowPatternVisitorAdapter() { + this(null); + } + + public RowPatternVisitorAdapter(ExpressionVisitor expressionVisitor) { + this.expressionVisitor = expressionVisitor; + } + + @Override + public T visit(RowPattern.Variable pattern, S context) { + return null; + } + + @Override + public T visit(RowPattern.Empty pattern, S context) { + return null; + } + + @Override + public T visit(RowPattern.Anchor pattern, S context) { + return null; + } + + @Override + public T visit(RowPattern.Group pattern, S context) { + return pattern.getPattern().accept(this, context); + } + + @Override + public T visit(RowPattern.Operation pattern, S context) { + for (RowPattern child : pattern.getPatterns()) { + child.accept(this, context); + } + return null; + } + + @Override + public T visit(RowPattern.Quantified pattern, S context) { + pattern.getPattern().accept(this, context); + if (expressionVisitor != null) { + if (pattern.getLowerBound() != null) { + pattern.getLowerBound().accept(expressionVisitor, context); + } + if (pattern.getUpperBound() != null) { + pattern.getUpperBound().accept(expressionVisitor, context); + } + } + return null; + } + + @Override + public T visit(RowPattern.Permute pattern, S context) { + for (RowPattern child : pattern.getPatterns()) { + child.accept(this, context); + } + return null; + } + + @Override + public T visit(RowPattern.Exclusion pattern, S context) { + return pattern.getPattern().accept(this, context); + } +} diff --git a/src/main/java/net/sf/jsqlparser/util/TablesNamesFinder.java b/src/main/java/net/sf/jsqlparser/util/TablesNamesFinder.java index 3bf8df2e0..4da6385d6 100644 --- a/src/main/java/net/sf/jsqlparser/util/TablesNamesFinder.java +++ b/src/main/java/net/sf/jsqlparser/util/TablesNamesFinder.java @@ -9,6 +9,9 @@ */ package net.sf.jsqlparser.util; +import net.sf.jsqlparser.statement.select.MatchRecognize; +import net.sf.jsqlparser.expression.RowPatternFunction; + import net.sf.jsqlparser.expression.AliasedExpression; import net.sf.jsqlparser.statement.oracle.OracleBlock; @@ -225,6 +228,7 @@ public class TablesNamesFinder private Set tables; private boolean allowColumnProcessing = false; + private Set rowPatternVariables = java.util.Collections.emptySet(); private List otherItemNames; @@ -391,26 +395,24 @@ public void visit(ParenthesedSelect parenthesedSelect) { @Override public Void visit(PlainSelect plainSelect, S context) { - List> withItemsList = plainSelect.getWithItemsList(); - if (withItemsList != null && !withItemsList.isEmpty()) { - for (WithItem withItem : withItemsList) { - withItem.accept((SelectVisitor) this, context); - } + Set previous = rowPatternVariables; + try { + rowPatternVariables = java.util.Collections.emptySet(); + return visitPlainSelectBody(plainSelect, context); + } finally { + rowPatternVariables = previous; } + } + + private Void visitPlainSelectBody(PlainSelect plainSelect, S context) { + visitWithItems(plainSelect.getWithItemsList(), context); if (plainSelect.getDistinct() != null) { visitSelectItems(plainSelect.getDistinct().getOnSelectItems(), context); } visitTables(plainSelect.getIntoTables(), context); - if (plainSelect.getSelectItems() != null) { - for (SelectItem item : plainSelect.getSelectItems()) { - item.accept(this, context); - } - } - - if (plainSelect.getFromItem() != null) { - plainSelect.getFromItem().accept(this, context); - } + visitSelectItems(plainSelect.getSelectItems(), context); + visitFromItem(plainSelect.getFromItem(), context); if (plainSelect.getLateralViews() != null) { for (LateralView lateralView : plainSelect.getLateralViews()) { @@ -419,27 +421,17 @@ public Void visit(PlainSelect plainSelect, S context) { } visitJoins(plainSelect.getJoins(), context); - if (plainSelect.getPreWhere() != null) { - plainSelect.getPreWhere().accept(this, context); - } - if (plainSelect.getWhere() != null) { - plainSelect.getWhere().accept(this, context); - } + visitExpression(plainSelect.getPreWhere(), context); + visitExpression(plainSelect.getWhere(), context); visitPreferringClause(plainSelect.getPreferringClause(), context); visit(plainSelect.getGroupBy(), context); - if (plainSelect.getHaving() != null) { - plainSelect.getHaving().accept(this, context); - } + visitExpression(plainSelect.getHaving(), context); - if (plainSelect.getQualify() != null) { - plainSelect.getQualify().accept(this, context); - } + visitExpression(plainSelect.getQualify(), context); - if (plainSelect.getOracleHierarchical() != null) { - plainSelect.getOracleHierarchical().accept(this, context); - } + visitExpression(plainSelect.getOracleHierarchical(), context); if (plainSelect.getWindowDefinitions() != null) { for (WindowDefinition windowDefinition : plainSelect.getWindowDefinitions()) { @@ -458,12 +450,7 @@ public Void visit(PlainSelect plainSelect, S context) { visitOrderBy(plainSelect.getOrderByElements(), context); visitLimit(plainSelect.getLimit(), context); visitLimit(plainSelect.getLimitBy(), context); - if (plainSelect.getOffset() != null) { - plainSelect.getOffset().getOffset().accept(this, context); - } - if (plainSelect.getFetch() != null) { - plainSelect.getFetch().getExpression().accept(this, context); - } + visitSelectPagination(plainSelect, context); visitUpdateSets(plainSelect.getSettings(), context); visitFromItem(plainSelect.getIntoTempTable(), context); return null; @@ -479,7 +466,7 @@ public Void visit(PivotQuery pivotQuery, S context) { visitOrderBy(pivotQuery.getOrderByElements(), context); visitLimit(pivotQuery.getLimit(), context); - visitPivotPagination(pivotQuery, context); + visitSelectPagination(pivotQuery, context); return null; } @@ -491,12 +478,12 @@ private void visitSelectItems(List> selectItems, S c } } - private void visitPivotPagination(PivotQuery pivotQuery, S context) { - if (pivotQuery.getOffset() != null) { - pivotQuery.getOffset().getOffset().accept(this, context); + private void visitSelectPagination(Select select, S context) { + if (select.getOffset() != null) { + select.getOffset().getOffset().accept(this, context); } - if (pivotQuery.getFetch() != null && pivotQuery.getFetch().getExpression() != null) { - pivotQuery.getFetch().getExpression().accept(this, context); + if (select.getFetch() != null && select.getFetch().getExpression() != null) { + select.getFetch().getExpression().accept(this, context); } } @@ -520,6 +507,33 @@ protected String extractTableName(Table table) { return table.getFullyQualifiedName(); } + @Override + public Void visit(RowPatternFunction function, S context) { + return function.getFunction().accept(this, context); + } + + @Override + public Void visit(MatchRecognize matchRecognize, S context) { + matchRecognize.getInput().accept(this, context); + if (matchRecognize.getPivot() != null) { + matchRecognize.getPivot().accept(this, context); + } + if (matchRecognize.getUnPivot() != null) { + matchRecognize.getUnPivot().accept(this, context); + } + matchRecognize.getPartitionBy().forEach(expression -> expression.accept(this, context)); + matchRecognize.getOrderByElements() + .forEach(order -> order.getExpression().accept(this, context)); + Set previous = rowPatternVariables; + try { + rowPatternVariables = matchRecognize.getPatternVariableNames(); + matchRecognize.forEachPatternExpression(expression -> expression.accept(this, context)); + } finally { + rowPatternVariables = previous; + } + return null; + } + @Override public Void visit(Table table, S context) { String tableWholeName = extractTableName(table); @@ -572,7 +586,9 @@ public Void visit(OverlapsCondition overlapsCondition, S context) { @Override public Void visit(Column tableColumn, S context) { if (allowColumnProcessing && tableColumn.getTable() != null - && tableColumn.getTable().getName() != null) { + && tableColumn.getTable().getName() != null + && !rowPatternVariables.contains( + MatchRecognize.normalizeVariableName(tableColumn.getTable().getName()))) { visit(tableColumn.getTable(), context); } return null; @@ -1221,6 +1237,7 @@ protected void init(boolean allowColumnProcessing) { otherItemNames = new ArrayList(); tables = new HashSet<>(); this.allowColumnProcessing = allowColumnProcessing; + this.rowPatternVariables = java.util.Collections.emptySet(); } @Override 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 1fbec9ef6..d5260d948 100644 --- a/src/main/java/net/sf/jsqlparser/util/deparser/ExpressionDeParser.java +++ b/src/main/java/net/sf/jsqlparser/util/deparser/ExpressionDeParser.java @@ -40,6 +40,7 @@ import net.sf.jsqlparser.expression.HexValue; import net.sf.jsqlparser.expression.HighExpression; import net.sf.jsqlparser.expression.IntervalExpression; +import net.sf.jsqlparser.expression.RowPatternFunction; import net.sf.jsqlparser.expression.Inverse; import net.sf.jsqlparser.expression.JdbcNamedParameter; import net.sf.jsqlparser.expression.JdbcParameter; @@ -143,7 +144,6 @@ import net.sf.jsqlparser.statement.select.OrderByElement; import net.sf.jsqlparser.statement.select.ParenthesedSelect; import net.sf.jsqlparser.statement.select.Select; -import net.sf.jsqlparser.statement.select.SelectItem; import net.sf.jsqlparser.statement.select.SelectVisitor; import net.sf.jsqlparser.statement.select.WithItem; @@ -1843,76 +1843,13 @@ public StringBuilder visit(TSQLRightJoin tsqlRightJoin, S context) { } @Override - public StringBuilder visit(StructType structType, S context) { - if (structType.getDialect() != StructType.Dialect.DUCKDB - && structType.getKeyword() != null) { - builder.append(structType.getKeyword()); - } - - if (structType.getDialect() != StructType.Dialect.DUCKDB - && structType.getParameters() != null && !structType.getParameters().isEmpty()) { - builder.append("<"); - int i = 0; - for (Map.Entry e : structType.getParameters()) { - if (0 < i++) { - builder.append(","); - } - // optional name - if (e.getKey() != null && !e.getKey().isEmpty()) { - builder.append(e.getKey()).append(" "); - } - - // mandatory type - builder.append(e.getValue()); - } - - builder.append(">"); - } - - if (structType.getArguments() != null && !structType.getArguments().isEmpty()) { - if (structType.getDialect() == StructType.Dialect.DUCKDB) { - builder.append("{ "); - int i = 0; - for (SelectItem e : structType.getArguments()) { - if (0 < i++) { - builder.append(","); - } - builder.append(e.getAlias().getName()); - builder.append(" : "); - e.getExpression().accept(this, context); - } - builder.append(" }"); - } else { - builder.append("("); - int i = 0; - for (SelectItem e : structType.getArguments()) { - if (0 < i++) { - builder.append(","); - } - e.getExpression().accept(this, context); - if (e.getAlias() != null) { - builder.append(" as "); - builder.append(e.getAlias().getName()); - } - } - builder.append(")"); - } - } + public StringBuilder visit(RowPatternFunction function, S context) { + return function.appendTo(builder, expression -> expression.accept(this, context)); + } - if (structType.getDialect() == StructType.Dialect.DUCKDB - && structType.getParameters() != null && !structType.getParameters().isEmpty()) { - builder.append("::STRUCT( "); - int i = 0; - for (Map.Entry e : structType.getParameters()) { - if (0 < i++) { - builder.append(","); - } - builder.append(e.getKey()).append(" "); - builder.append(e.getValue()); - } - builder.append(")"); - } - return builder; + @Override + public StringBuilder visit(StructType structType, S context) { + return structType.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 0dc7d84b8..dc5671f0f 100644 --- a/src/main/java/net/sf/jsqlparser/util/deparser/SelectDeParser.java +++ b/src/main/java/net/sf/jsqlparser/util/deparser/SelectDeParser.java @@ -9,6 +9,8 @@ */ package net.sf.jsqlparser.util.deparser; +import net.sf.jsqlparser.statement.select.MatchRecognize; + import static java.util.stream.Collectors.joining; import java.lang.reflect.InvocationTargetException; @@ -586,6 +588,15 @@ public StringBuilder visit(SelectItem selectItem, S context) { } + @Override + public StringBuilder visit(MatchRecognize matchRecognize, S context) { + OrderByDeParser ordering = new OrderByDeParser(expressionVisitor, builder); + return matchRecognize.appendTo(builder, input -> input.accept(this, context), + expression -> expression.accept(expressionVisitor, context), + order -> ordering.deParseElement(order, context), + pivot -> pivot.accept(this, context), unpivot -> unpivot.accept(this, context)); + } + @Override public StringBuilder visit(Table table, S context) { builder.append(table.getFullyQualifiedName()); diff --git a/src/main/java/net/sf/jsqlparser/util/validation/feature/FeaturesAllowed.java b/src/main/java/net/sf/jsqlparser/util/validation/feature/FeaturesAllowed.java index 0048f8db8..00e0d7a5f 100644 --- a/src/main/java/net/sf/jsqlparser/util/validation/feature/FeaturesAllowed.java +++ b/src/main/java/net/sf/jsqlparser/util/validation/feature/FeaturesAllowed.java @@ -45,7 +45,7 @@ public class FeaturesAllowed implements FeatureSetValidation, ModifyableFeatureS */ public static final FeaturesAllowed SELECT = new FeaturesAllowed("SELECT", // select features - Feature.select, + Feature.select, Feature.matchRecognize, Feature.matchRecognizeOptions, Feature.selectGroupBy, Feature.selectHaving, diff --git a/src/main/java/net/sf/jsqlparser/util/validation/feature/OracleVersion.java b/src/main/java/net/sf/jsqlparser/util/validation/feature/OracleVersion.java index 53d9bdf74..8d94ab72c 100644 --- a/src/main/java/net/sf/jsqlparser/util/validation/feature/OracleVersion.java +++ b/src/main/java/net/sf/jsqlparser/util/validation/feature/OracleVersion.java @@ -29,6 +29,7 @@ public enum OracleVersion implements Version { Feature.jdbcNamedParameter, // expressions Feature.exprLike, // common features // https://docs.oracle.com/en/database/oracle/oracle-database/19/sqlrf/SELECT.html + Feature.matchRecognize, Feature.select, // https://www.oracletutorial.com/oracle-basics/oracle-group-by/ Feature.selectGroupBy, Feature.function, // https://www.oracletutorial.com/oracle-basics/oracle-grouping-sets/ Feature.selectGroupByGroupingSets, // https://www.oracletutorial.com/oracle-basics/oracle-having/ 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 f6e493177..757785731 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 @@ -9,6 +9,10 @@ */ package net.sf.jsqlparser.util.validation.validator; +import java.util.Set; +import java.util.Collections; +import net.sf.jsqlparser.statement.select.MatchRecognize; + import java.util.Map; import net.sf.jsqlparser.expression.AllValue; import net.sf.jsqlparser.expression.AnalyticExpression; @@ -146,6 +150,8 @@ @SuppressWarnings({"PMD.CyclomaticComplexity"}) public class ExpressionValidator extends AbstractValidator implements ExpressionVisitor { + private Set rowPatternVariables = Collections.emptySet(); + @Override public Void visit(Addition addition, S context) { visitBinaryExpression(addition, " + "); @@ -533,13 +539,30 @@ public Void visit(ParenthesedSelect selectBody, S context) { return null; } + void validateMatchRecognizeExpressions(MatchRecognize matchRecognize) { + Set previous = rowPatternVariables; + try { + rowPatternVariables = matchRecognize.getPatternVariableNames(); + matchRecognize + .forEachPatternExpression( + expression -> validateOptionalExpression(expression, this)); + } finally { + rowPatternVariables = previous; + } + } + @Override public Void visit(Column tableColumn, S context) { if (tableColumn .getOldOracleJoinSyntax() != SupportsOldOracleJoinSyntax.NO_ORACLE_JOIN) { validateFeature(Feature.oracleOldJoinSyntax); } - validateName(NamedObject.column, tableColumn.getFullyQualifiedName()); + // A pattern qualifier is not a schema/table name. Resolving its columns + // requires binding the input relation, which metadata validation does not do. + if (tableColumn.getTable() == null || !rowPatternVariables.contains( + MatchRecognize.normalizeVariableName(tableColumn.getTable().getName()))) { + validateName(NamedObject.column, tableColumn.getFullyQualifiedName()); + } return null; } @@ -1153,7 +1176,8 @@ public Void visit(GeometryDistance geometryDistance, S context) { @Override public Void visit(Select select, S context) { - return null; + return select.accept((net.sf.jsqlparser.statement.select.SelectVisitor) getValidator( + SelectValidator.class), context); } @Override diff --git a/src/main/java/net/sf/jsqlparser/util/validation/validator/MatchRecognizeValidator.java b/src/main/java/net/sf/jsqlparser/util/validation/validator/MatchRecognizeValidator.java new file mode 100644 index 000000000..051ca5dba --- /dev/null +++ b/src/main/java/net/sf/jsqlparser/util/validation/validator/MatchRecognizeValidator.java @@ -0,0 +1,245 @@ +/*- + * #%L + * JSQLParser library + * %% + * Copyright (C) 2004 - 2026 JSQLParser + * %% + * Dual licensed under GNU LGPL 2.1 or Apache License 2.0 + * #L% + */ +package net.sf.jsqlparser.util.validation.validator; + +import java.math.BigInteger; +import java.util.HashSet; +import java.util.Locale; +import java.util.Set; +import net.sf.jsqlparser.expression.Expression; +import net.sf.jsqlparser.expression.ExpressionVisitorAdapter; +import net.sf.jsqlparser.expression.JdbcParameter; +import net.sf.jsqlparser.expression.LongValue; +import net.sf.jsqlparser.expression.RowPatternFunction; +import net.sf.jsqlparser.expression.UserVariable; +import net.sf.jsqlparser.parser.AbstractJSqlParser.Dialect; +import net.sf.jsqlparser.parser.feature.Feature; +import net.sf.jsqlparser.schema.MultiPartName; +import net.sf.jsqlparser.statement.select.MatchRecognize; +import net.sf.jsqlparser.statement.select.RowPattern; +import net.sf.jsqlparser.statement.select.RowPatternVisitorAdapter; +import net.sf.jsqlparser.util.validation.ValidationCapability; +import net.sf.jsqlparser.util.validation.ValidationException; + +/** + * Static dialect checks; schema binding, function eligibility and runtime checks remain with the + * DB. + */ +public class MatchRecognizeValidator extends AbstractValidator { + @Override + public void validate(MatchRecognize match) { + String dialect = context().getAsString(Feature.dialect); + boolean bigQuery = Dialect.BIGQUERY.name().equals(dialect); + boolean oracle = Dialect.ORACLE.name().equals(dialect); + boolean snowflake = Dialect.SNOWFLAKE.name().equals(dialect); + if (!bigQuery && !oracle && !snowflake) { + return; + } + validateDialectOptions(match, bigQuery, oracle); + validateRowOutput(match, oracle); + Set used = validateVariables(match, bigQuery, oracle); + validateTargets(match, used, bigQuery); + match.getPattern().accept(new PatternValidator(match, bigQuery, oracle), null); + match.getMeasures() + .forEach(measure -> checkFunctions(measure.getExpression(), bigQuery, false)); + match.getDefinitions() + .forEach(definition -> checkFunctions(definition.getExpression(), bigQuery, true)); + } + + private void validateDialectOptions(MatchRecognize match, boolean bigQuery, boolean oracle) { + if (bigQuery) { + if (match.getOrderByElements().isEmpty() || match.getMeasures().isEmpty()) { + error("BigQuery MATCH_RECOGNIZE requires ORDER BY and MEASURES"); + } + if (match.getRowsPerMatch() != null || match.getSkipVariable() != null) { + error("BigQuery does not support ROWS PER MATCH or SKIP TO a variable"); + } + } else if (match.getOptions() != null) { + error("MATCH_RECOGNIZE OPTIONS is specific to BigQuery"); + } + if (!oracle && !match.getSubsets().isEmpty()) { + error("SUBSET is specific to Oracle"); + } + } + + private void validateRowOutput(MatchRecognize match, boolean oracle) { + if (match.getEmptyMatchMode() != null + && match.getRowsPerMatch() != MatchRecognize.RowsPerMatch.ALL) { + error("Empty match options require ALL ROWS PER MATCH"); + } + match.getMeasures().forEach(measure -> { + if (measure.getAlias() == null || oracle && !measure.getAlias().isUseAs()) { + error("A measure requires an alias (with AS in Oracle)"); + } + }); + + } + + private Set validateVariables(MatchRecognize match, boolean bigQuery, boolean oracle) { + Set used = new HashSet<>(); + match.getPattern().accept(new RowPatternVisitorAdapter() { + @Override + public Void visit(RowPattern.Variable variable, S context) { + used.add(normalize(variable.getName(), bigQuery)); + return null; + } + }, null); + Set defined = new HashSet<>(); + for (MatchRecognize.Definition definition : match.getDefinitions()) { + String name = normalize(definition.getName(), bigQuery); + if (!defined.add(name)) { + error("Duplicate row pattern variable: " + definition.getName()); + } + if ((bigQuery || oracle) && !used.contains(name)) { + error("Unused row pattern variable: " + definition.getName()); + } + } + if (bigQuery) { + for (String name : used) { + if (!defined.contains(name)) { + error("Undefined row pattern variable: " + name); + } + } + } + return used; + } + + private void validateTargets(MatchRecognize match, Set used, boolean bigQuery) { + Set subsetNames = new HashSet<>(); + for (MatchRecognize.Subset subset : match.getSubsets()) { + String name = normalize(subset.getName(), false); + if (used.contains(name) || !subsetNames.add(name)) { + error("Duplicate SUBSET variable: " + subset.getName()); + } + for (String variable : subset.getVariables()) { + if (!used.contains(normalize(variable, false))) { + error("SUBSET requires a primary pattern variable: " + variable); + } + } + } + if (match.getSkipVariable() != null + && !used.contains(normalize(match.getSkipVariable(), bigQuery)) + && !subsetNames.contains(normalize(match.getSkipVariable(), false))) { + error("Unknown AFTER MATCH SKIP variable: " + match.getSkipVariable()); + } + } + + private class PatternValidator extends RowPatternVisitorAdapter { + private final MatchRecognize match; + private final boolean bigQuery; + private final boolean oracle; + + private PatternValidator(MatchRecognize match, boolean bigQuery, boolean oracle) { + this.match = match; + this.bigQuery = bigQuery; + this.oracle = oracle; + } + + @Override + public Void visit(RowPattern.Quantified quantified, S context) { + checkBound(quantified.getLowerBound(), bigQuery); + checkBound(quantified.getUpperBound(), bigQuery); + if (quantified.getLowerBound() instanceof LongValue + && quantified.getUpperBound() instanceof LongValue + && ((LongValue) quantified.getLowerBound()).getBigIntegerValue() + .compareTo(((LongValue) quantified.getUpperBound()) + .getBigIntegerValue()) > 0) { + error("Row pattern upper bound is smaller than its lower bound"); + } + if (bigQuery && quantified.getType() == RowPattern.Quantified.Type.EXACT + && quantified.isReluctant()) { + error("BigQuery does not support reluctant fixed quantifiers"); + } + if (oracle) { + Expression maximum = quantified.getType() == RowPattern.Quantified.Type.EXACT + ? quantified.getLowerBound() + : quantified.getUpperBound(); + if (maximum instanceof LongValue + && ((LongValue) maximum).getBigIntegerValue().signum() == 0) { + error("Oracle row pattern quantifier maximum must be positive"); + } + } + return super.visit(quantified, context); + } + + @Override + public Void visit(RowPattern.Operation operation, S context) { + if (oracle && operation.getType() == RowPattern.Operation.Type.ALTERNATION + && operation.getPatterns().stream() + .anyMatch(RowPattern.Empty.class::isInstance)) { + error("Oracle requires a pattern on each side of an alternative"); + } + return super.visit(operation, context); + } + + @Override + public Void visit(RowPattern.Exclusion exclusion, S context) { + if (bigQuery) { + error("BigQuery does not support row pattern exclusion"); + } + if (match.getEmptyMatchMode() == MatchRecognize.EmptyMatchMode.WITH_UNMATCHED) { + error("Row pattern exclusion cannot be combined with WITH UNMATCHED ROWS"); + } + return super.visit(exclusion, context); + } + + @Override + public Void visit(RowPattern.Permute permute, S context) { + if (bigQuery) { + error("BigQuery does not support PERMUTE"); + } + return super.visit(permute, context); + } + } + + private void checkFunctions(Expression expression, boolean bigQuery, boolean definition) { + expression.accept(new ExpressionVisitorAdapter() { + @Override + public Void visit(RowPatternFunction function, S context) { + if (bigQuery) { + error("BigQuery does not support RUNNING or FINAL function prefixes"); + } + if (definition && function + .getEvaluationMode() == RowPatternFunction.EvaluationMode.FINAL) { + error("FINAL is only allowed in MATCH_RECOGNIZE MEASURES"); + } + return function.getFunction().accept(this, context); + } + }, null); + } + + private static String normalize(String name, boolean bigQuery) { + String unquoted = MultiPartName.unquote(name); + return bigQuery || name.equals(unquoted) ? unquoted.toUpperCase(Locale.ROOT) : unquoted; + } + + private void checkBound(Expression bound, boolean bigQuery) { + if (bound == null) { + return; + } + if (bound instanceof LongValue) { + BigInteger value = ((LongValue) bound).getBigIntegerValue(); + if (value.signum() < 0 || bigQuery && value.compareTo(BigInteger.valueOf(10000)) > 0) { + error(bigQuery ? "BigQuery row pattern bounds must be between 0 and 10000" + : "Row pattern bounds must be nonnegative"); + } + } else if (!bigQuery || !(bound instanceof JdbcParameter) + && !(bound instanceof UserVariable && !((UserVariable) bound).isDoubleAdd())) { + error(bigQuery ? "A row pattern bound must be an integer literal or query parameter" + : "A row pattern bound must be an integer literal"); + } + } + + private void error(String message) { + for (ValidationCapability capability : getCapabilities()) { + putError(capability, new ValidationException(message)); + } + } +} 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 71c33f902..b8347c325 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 @@ -9,6 +9,8 @@ */ package net.sf.jsqlparser.util.validation.validator; +import net.sf.jsqlparser.statement.select.MatchRecognize; + import java.util.List; import net.sf.jsqlparser.expression.Expression; import net.sf.jsqlparser.expression.MySQLIndexHint; @@ -59,6 +61,22 @@ public class SelectValidator extends AbstractValidator> PivotVisitor { @SuppressWarnings({"PMD.CyclomaticComplexity"}) + @Override + public Void visit(MatchRecognize matchRecognize, S context) { + validateFeature(Feature.matchRecognize); + if (matchRecognize.getOptions() != null) { + validateFeature(Feature.matchRecognizeOptions); + } + validateOptionalFromItem(matchRecognize.getInput()); + validateOptional(matchRecognize.getPivot(), p -> p.accept(this, context)); + validateOptional(matchRecognize.getUnPivot(), p -> p.accept(this, context)); + validateOptionalExpressions(matchRecognize.getPartitionBy()); + validateOptionalOrderByElements(matchRecognize.getOrderByElements()); + getValidator(MatchRecognizeValidator.class).validate(matchRecognize); + getValidator(ExpressionValidator.class).validateMatchRecognizeExpressions(matchRecognize); + return null; + } + @Override public Void visit(PlainSelect plainSelect, S context) { if (isNotEmpty(plainSelect.getWithItemsList())) { diff --git a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt index 4ab8d1505..b0068a076 100644 --- a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt +++ b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt @@ -1237,6 +1237,19 @@ public class CCJSqlParser extends AbstractJSqlParser { * Instead it goes directly to follower-based disambiguation for * reserved keywords. */ + private boolean isMatchRecognizeAhead() { + return isKeywordAhead("MATCH_RECOGNIZE") && getToken(2).kind == OPENING_BRACKET; + } + + private boolean isRowPatternPrimaryAhead() { + Token next = getToken(1); + return next.kind == OPENING_BRACKET || next.kind == OP_CARET + || next.kind == OPENING_CURLY_BRACKET && "-".equals(getToken(2).image) + || next.kind == S_IDENTIFIER || next.kind == S_QUOTED_IDENTIFIER + || next.kind == DATA_TYPE + || next.kind >= MIN_NON_RESERVED_WORD && next.kind <= MAX_NON_RESERVED_WORD; + } + private boolean isAliasAhead() { if (isAsOfJoinAhead()) { return false; @@ -1807,10 +1820,23 @@ TOKEN_MGR_DECLS : { return -1; } + private RowPatternTokenContext rowPatternContext; + public void CommonTokenAction(Token t) { t.absoluteBegin = getCurrentTokenAbsolutePosition(); t.absoluteEnd = t.absoluteBegin + t.image.length(); + // Track the lexical region independently of parser lookahead: nested SELECT + // disambiguation can fetch PATTERN tokens before its production is entered. + if (rowPatternContext == null && t.kind == S_IDENTIFIER + && "MATCH_RECOGNIZE".equalsIgnoreCase(t.image) + && AbstractJSqlParser.Dialect.BIGQUERY.name().equals(configuration.getValue(Feature.dialect))) { + rowPatternContext = new RowPatternTokenContext(); + } + if (rowPatternContext != null) { + rowPatternContext.accept(t); + if (rowPatternContext.isIdle()) { rowPatternContext = null; } + } } public int getCurrentTokenAbsolutePosition() @@ -2577,7 +2603,12 @@ TOKEN: | { - consumeDollarQuotedToken(matchedToken, matchedToken.image); + if (rowPatternContext != null && rowPatternContext.isInPattern()) { + truncateIdentifierToken(matchedToken, 1); + matchedToken.kind = S_IDENTIFIER; + } else { + consumeDollarQuotedToken(matchedToken, matchedToken.image); + } } | // Bare `#` as a binary operator (PostgreSQL bitwise XOR / geometric @@ -2588,6 +2619,10 @@ TOKEN: | ()*) | "$" | ("$" ()*)> { + if (rowPatternContext != null && rowPatternContext.isInPattern()) { + int dollar = matchedToken.image.indexOf('$'); + if (dollar >= 0) { truncateIdentifierToken(matchedToken, Math.max(1, dollar)); } + } if (matchedToken.image.charAt(0) == '$' && Boolean.TRUE.equals(configuration.getValue(Feature.allowDollarQuotedStringTags))) { String delimiter = StringValue.getDollarQuoteDelimiter(matchedToken.image); @@ -7641,14 +7676,22 @@ FromItem FromItem() #FromItem: LOOKAHEAD({ getAsBoolean(Feature.allowUnparenthesizedSubSelects) }) fromItem=Select() ) - [ LOOKAHEAD({ isAliasAhead() }) alias=Alias() { fromItem.setAlias(alias); } ] + [ LOOKAHEAD({ !isMatchRecognizeAhead() && isAliasAhead() }) alias=Alias() { fromItem.setAlias(alias); } ] [ LOOKAHEAD(2, {fromItem instanceof Table }) timeTravelStr = TimeTravelAfterAlias() { ((Table) fromItem).setTimeTravelStrAfterAlias(timeTravelStr); } ] - [ LOOKAHEAD(2) sampleClause = SampleClause() { fromItem.setSampleClause(sampleClause); } ] - [ LOOKAHEAD(2) unpivot=UnPivot() { fromItem.setUnPivot(unpivot); } ] - [ LOOKAHEAD(2) ( LOOKAHEAD(2) pivot=PivotXml() | pivot=Pivot() ) { fromItem.setPivot(pivot); } ] + ( + LOOKAHEAD({ isMatchRecognizeAhead() }) fromItem=MatchRecognize(fromItem) + [ LOOKAHEAD(2) ( LOOKAHEAD(2) pivot=PivotXml() | pivot=Pivot() ) { fromItem.setPivot(pivot); } ] + [ LOOKAHEAD(2) unpivot=UnPivot() { fromItem.setUnPivot(unpivot); } ] + [ LOOKAHEAD({ isAliasAhead() }) alias=Alias() { fromItem.setAlias(alias); } ] + [ LOOKAHEAD(2) sampleClause=SampleClause() { fromItem.setSampleClause(sampleClause); } ] + | + [ LOOKAHEAD(2) sampleClause = SampleClause() { fromItem.setSampleClause(sampleClause); } ] + [ LOOKAHEAD(2) unpivot=UnPivot() { fromItem.setUnPivot(unpivot); } ] + [ LOOKAHEAD(2) ( LOOKAHEAD(2) pivot=PivotXml() | pivot=Pivot() ) { fromItem.setPivot(pivot); } ] + ) [ LOOKAHEAD(2) ( @@ -7669,6 +7712,265 @@ FromItem FromItem() #FromItem: } } + +MatchRecognize MatchRecognize(FromItem input) #MatchRecognize: +{ + MatchRecognize result = new MatchRecognize(input); + ExpressionList partition; + List ordering; + List> measures; + MatchRecognize.Definition definition; + MatchRecognize.Subset subset; + RowPattern pattern; + MatchRecognize.Options options = null; + String variable; + Token value; +} +{ + ContextualKeyword("MATCH_RECOGNIZE") "(" + [ partition=SimpleExpressionList() { result.setPartitionBy(partition); } ] + [ LOOKAHEAD(2) ordering=OrderByElements() { result.setOrderByElements(ordering); } ] + [ LOOKAHEAD({ isKeywordAhead("MEASURES") }) ContextualKeyword("MEASURES") measures=SelectItemsList() + { + for (SelectItem measure : measures) { + if (measure.getAlias() == null) { throw new ParseException("MATCH_RECOGNIZE measures require an alias"); } + } + result.setMeasures(measures); + } + ] + [ + LOOKAHEAD({ isKeywordAhead("ONE") || getToken(1).kind == K_ALL }) + ( LOOKAHEAD({ isKeywordAhead("ONE") }) ContextualKeyword("ONE") ContextualKeyword("PER") + { result.setRowsPerMatch(MatchRecognize.RowsPerMatch.ONE); } + | ContextualKeyword("PER") + { result.setRowsPerMatch(MatchRecognize.RowsPerMatch.ALL); } + [ + ContextualKeyword("MATCHES") + { result.setEmptyMatchMode(MatchRecognize.EmptyMatchMode.SHOW); } + | LOOKAHEAD({ isKeywordAhead("OMIT") }) ContextualKeyword("OMIT") ContextualKeyword("MATCHES") + { result.setEmptyMatchMode(MatchRecognize.EmptyMatchMode.OMIT); } + | ContextualKeyword("UNMATCHED") + { result.setEmptyMatchMode(MatchRecognize.EmptyMatchMode.WITH_UNMATCHED); } + ] + ) + ] + [ LOOKAHEAD({ isKeywordAhead("AFTER") }) ContextualKeyword("AFTER") + ( LOOKAHEAD({ isKeywordAhead("PAST") }) ContextualKeyword("PAST") + { result.setSkipMode(MatchRecognize.SkipMode.PAST_LAST_ROW); } + | + ( LOOKAHEAD(2) { result.setSkipMode(MatchRecognize.SkipMode.TO_NEXT_ROW); } + | { result.setSkipMode(MatchRecognize.SkipMode.TO_VARIABLE); } + [ LOOKAHEAD({ (getToken(1).kind == K_FIRST || getToken(1).kind == K_LAST) + && !("PATTERN".equalsIgnoreCase(getToken(2).image) && getToken(3).kind == OPENING_BRACKET) }) + ( { result.setSkipMode(MatchRecognize.SkipMode.TO_FIRST); } + | { result.setSkipMode(MatchRecognize.SkipMode.TO_LAST); } ) ] + variable=RelObjectName() { result.setSkipVariable(variable); } + ) + ) + ] + ContextualKeyword("PATTERN") "(" pattern=RowPattern(0) ")" { result.setPattern(pattern); } + [ LOOKAHEAD({ isKeywordAhead("SUBSET") }) ContextualKeyword("SUBSET") subset=MatchRecognizeSubset() + { result.getSubsets().add(subset); } + ( LOOKAHEAD(2) "," subset=MatchRecognizeSubset() { result.getSubsets().add(subset); } )* + ] + ContextualKeyword("DEFINE") definition=MatchRecognizeDefinition() { result.getDefinitions().add(definition); } + ( LOOKAHEAD(2) "," definition=MatchRecognizeDefinition() { result.getDefinitions().add(definition); } )* + [ LOOKAHEAD({ isKeywordAhead("OPTIONS") }) ContextualKeyword("OPTIONS") "(" + { options = new MatchRecognize.Options(); } + [ LOOKAHEAD({ isKeywordAhead("use_longest_match") }) ContextualKeyword("use_longest_match") "=" + ( value= | value= ) { options.setUseLongestMatch(value.kind == K_TRUE); } + ] + ")" { result.setOptions(options); } + ] + ")" + { + if (Dialect.BIGQUERY.name().equals(getAsString(Feature.dialect))) { + if (result.getOrderByElements().isEmpty() || result.getMeasures().isEmpty()) { + throw new ParseException("BigQuery MATCH_RECOGNIZE requires ORDER BY and MEASURES"); + } + if (result.getRowsPerMatch() != null || result.getSkipVariable() != null || !result.getSubsets().isEmpty()) { + throw new ParseException("BigQuery does not support ROWS PER MATCH, SKIP TO a variable or SUBSET"); + } + } + linkAST(result, jjtThis); return result; + } +} + +MatchRecognize.Subset MatchRecognizeSubset() #MatchRecognizeSubset: +{ + String name; + String variable; + List variables = new ArrayList(); +} +{ + name=RelObjectName() "=" "(" variable=RelObjectName() { variables.add(variable); } + ( "," variable=RelObjectName() { variables.add(variable); } )* ")" + { + MatchRecognize.Subset result = new MatchRecognize.Subset(name, variables); + linkAST(result, jjtThis); return result; + } +} + +MatchRecognize.Definition MatchRecognizeDefinition() #MatchRecognizeDefinition: +{ + String name; + Expression expression; +} +{ + name=RelObjectName() expression=Expression() + { + MatchRecognize.Definition result = new MatchRecognize.Definition(name, expression); + linkAST(result, jjtThis); + return result; + } +} + +RowPattern RowPattern(int depth) #RowPattern: +{ + List alternatives = new ArrayList(); + RowPattern pattern; + Token separator; +} +{ + { + if (depth > getAsLong(Feature.allowedNestingDepth)) { + throw new ParseException("Row pattern exceeds the allowed nesting depth"); + } + } + ( + LOOKAHEAD({ Dialect.SNOWFLAKE.name().equals(getAsString(Feature.dialect)) }) + pattern=SnowflakeRowPatternSequence(depth) { alternatives.add(pattern); } + | + pattern=RowPatternSequence(depth) { alternatives.add(pattern); } + ( ( separator= | separator= ) + { if (separator.kind == OP_CONCAT) { alternatives.add(new RowPattern.Empty()); } } + pattern=RowPatternSequence(depth) { alternatives.add(pattern); } + )* + ) + { + RowPattern result = alternatives.size() == 1 ? alternatives.get(0) + : new RowPattern.Operation(RowPattern.Operation.Type.ALTERNATION, alternatives); + linkAST(result, jjtThis); + return result; + } +} + +RowPattern SnowflakeRowPatternSequence(int depth) #SnowflakeRowPatternSequence: +{ + List patterns = new ArrayList(); + RowPattern pattern; +} +{ + ( LOOKAHEAD({ isRowPatternPrimaryAhead() }) pattern=SnowflakeRowPatternAlternative(depth) { patterns.add(pattern); } )* + { + RowPattern result = patterns.isEmpty() ? new RowPattern.Empty() + : patterns.size() == 1 ? patterns.get(0) + : new RowPattern.Operation(RowPattern.Operation.Type.SEQUENCE, patterns); + linkAST(result, jjtThis); return result; + } +} + +RowPattern SnowflakeRowPatternAlternative(int depth) #SnowflakeRowPatternAlternative: +{ + List patterns = new ArrayList(); + RowPattern pattern; +} +{ + pattern=RowPatternFactor(depth) { patterns.add(pattern); } + ( pattern=RowPatternFactor(depth) { patterns.add(pattern); } )* + { + RowPattern result = patterns.size() == 1 ? patterns.get(0) + : new RowPattern.Operation(RowPattern.Operation.Type.ALTERNATION, patterns); + linkAST(result, jjtThis); return result; + } +} + +RowPattern RowPatternSequence(int depth): +{ + List patterns = new ArrayList(); + RowPattern pattern; +} +{ + ( LOOKAHEAD({ isRowPatternPrimaryAhead() }) pattern=RowPatternFactor(depth) { patterns.add(pattern); } )* + { + if (patterns.isEmpty()) { return new RowPattern.Empty(); } + return patterns.size() == 1 ? patterns.get(0) + : new RowPattern.Operation(RowPattern.Operation.Type.SEQUENCE, patterns); + } +} + +RowPattern RowPatternFactor(int depth) #RowPatternFactor: +{ + RowPattern pattern; + String name; + List permutations = new ArrayList(); +} +{ + ( + LOOKAHEAD({ isKeywordAhead("PERMUTE") && getToken(2).kind == OPENING_BRACKET }) + ContextualKeyword("PERMUTE") "(" + pattern=RowPattern(depth + 1) { permutations.add(pattern); } + ( "," pattern=RowPattern(depth + 1) { permutations.add(pattern); } )* ")" + { pattern = new RowPattern.Permute(permutations); } + | "{" "-" pattern=RowPattern(depth + 1) "-" "}" { pattern = new RowPattern.Exclusion(pattern); } + | { pattern = new RowPattern.Anchor(RowPattern.Anchor.Type.START); } + | LOOKAHEAD({ "$".equals(getToken(1).image) }) + { pattern = new RowPattern.Anchor(RowPattern.Anchor.Type.END); } + | "(" pattern=RowPattern(depth + 1) ")" { pattern = new RowPattern.Group(pattern); } + | name=RelObjectName() { pattern = new RowPattern.Variable(name); } + ) + { + if (Dialect.BIGQUERY.name().equals(getAsString(Feature.dialect)) + && (pattern instanceof RowPattern.Permute || pattern instanceof RowPattern.Exclusion)) { + throw new ParseException("BigQuery does not support PERMUTE or row pattern exclusion"); + } + } + [ LOOKAHEAD({ !(pattern instanceof RowPattern.Anchor) + && ("?".equals(getToken(1).image) || "*".equals(getToken(1).image) + || "+".equals(getToken(1).image) || getToken(1).kind == OPENING_CURLY_BRACKET) }) + pattern=RowPatternQuantifier(pattern) + ] + { linkAST(pattern, jjtThis); return pattern; } +} + +RowPattern RowPatternQuantifier(RowPattern pattern) #RowPatternQuantifier: +{ + RowPattern.Quantified result; + Expression lower = null; + Expression upper = null; + boolean range = false; +} +{ + ( + "*" { result = new RowPattern.Quantified(pattern, RowPattern.Quantified.Type.ZERO_OR_MORE); } + | "+" { result = new RowPattern.Quantified(pattern, RowPattern.Quantified.Type.ONE_OR_MORE); } + | "?" { result = new RowPattern.Quantified(pattern, RowPattern.Quantified.Type.OPTIONAL); } + | "{" [ LOOKAHEAD({ !",".equals(getToken(1).image) }) lower=RowPatternBound() ] + [ "," { range = true; } [ LOOKAHEAD({ getToken(1).kind != CLOSING_CURLY_BRACKET }) upper=RowPatternBound() ] ] "}" + { + if (lower == null && upper == null) { throw new ParseException("Row pattern quantifier requires a bound"); } + result = new RowPattern.Quantified(pattern, range ? RowPattern.Quantified.Type.RANGE : RowPattern.Quantified.Type.EXACT) + .setLowerBound(lower).setUpperBound(upper); + } + ) + [ LOOKAHEAD({ (result.getType() != RowPattern.Quantified.Type.EXACT + || !Dialect.BIGQUERY.name().equals(getAsString(Feature.dialect))) && "?".equals(getToken(1).image) }) "?" { result.setReluctant(true); } ] + { linkAST(result, jjtThis); return result; } +} + +Expression RowPatternBound(): +{ + Token value; + Expression result; +} +{ + ( value= { result = new LongValue(value.image); } + | "?" { result = new JdbcParameter(++jdbcParameterIndex, false, "?"); } + | result=UserVariable() + ) + { return result; } +} + List JoinsList(): { List joinsList = new ArrayList(); @@ -9555,6 +9857,9 @@ Expression PrimaryExpression() #PrimaryExpression: | LOOKAHEAD(2, {!interrupted}) retval= CastExpression() + | LOOKAHEAD({ !interrupted && (isKeywordAhead("RUNNING") || getToken(1).kind == K_FINAL) + && getToken(3).kind == OPENING_BRACKET }) retval=RowPatternFunction() + | LOOKAHEAD({ !interrupted && isFunctionAhead() }) retval = Function() [ LOOKAHEAD(2) retval = AnalyticExpression( (Function) retval ) ] @@ -11416,6 +11721,23 @@ LambdaExpression LambdaExpression() #LambdaExpression: } } +RowPatternFunction RowPatternFunction() #RowPatternFunction: +{ + RowPatternFunction.EvaluationMode mode; + Function function; +} +{ + ( LOOKAHEAD({ isKeywordAhead("RUNNING") }) ContextualKeyword("RUNNING") + { mode = RowPatternFunction.EvaluationMode.RUNNING; } + | { mode = RowPatternFunction.EvaluationMode.FINAL; } + ) + function=Function() + { + RowPatternFunction result = new RowPatternFunction(mode, function); + linkAST(result, jjtThis); return result; + } +} + Function Function() #Function: { Function function; diff --git a/src/site/sphinx/usage.rst b/src/site/sphinx/usage.rst index 814888bef..295150538 100644 --- a/src/site/sphinx/usage.rst +++ b/src/site/sphinx/usage.rst @@ -856,6 +856,69 @@ Informix's constraint form requires an explicit dialect selection: + "REFERENCES parent(id) CONSTRAINT fk_child", parser -> parser.withDialect(Dialect.INFORMIX)); +Row pattern matching (MATCH_RECOGNIZE) +-------------------------------------- + +``MatchRecognize`` is a ``FromItem`` wrapping the input relation. Its input +alias and output alias are independent. ``getMeasures()`` and +``getDefinitions()`` expose ordinary SQL expressions; ``getPattern()`` exposes +an editable ``RowPattern`` tree with variables, groups, ordered alternatives, +sequences, anchors and quantifiers. Oracle and Snowflake additionally support +``PERMUTE`` and exclusion nodes. Permutations remain compact in the AST. + +.. code-block:: java + + PlainSelect select = (PlainSelect) CCJSqlParserUtil.parse( + "SELECT * FROM events MATCH_RECOGNIZE (" + + "ORDER BY seq MEASURES SUM(A.price) AS total " + + "PATTERN (A+) DEFINE A AS price > 0 " + + "OPTIONS (use_longest_match = TRUE))", + parser -> parser.withDialect(Dialect.BIGQUERY)); + MatchRecognize match = (MatchRecognize) select.getFromItem(); + RowPattern.Quantified repetition = (RowPattern.Quantified) match.getPattern(); + repetition.setReluctant(true); + System.out.println(select); // PATTERN (A+?) + +``FromItemVisitorAdapter`` traverses the input and clause expressions. +``RowPatternVisitorAdapter`` traverses pattern nodes and optionally visits +expressions in quantifier bounds. ``RowPatternFunction`` represents an explicit +``RUNNING`` or ``FINAL`` prefix while exposing the underlying ``Function`` to +existing expression visitors. Both ``toString()`` and the deparsers render the +current AST, including edited nested ``STRUCT`` arguments. + +Select the source dialect explicitly: + +* ``BIGQUERY`` requires ``ORDER BY``, ``MEASURES``, ``PATTERN`` and ``DEFINE``. + It supports empty alternatives, literal or query-parameter bounds, adjacent + anchors such as ``^A+$``, and ``OPTIONS (use_longest_match = TRUE/FALSE)``. +* ``ORACLE`` supports optional ordering/measures, row output modes, targeted + ``AFTER MATCH SKIP``, ``SUBSET``, permutations, exclusions and function modes. +* ``SNOWFLAKE`` supports the corresponding documented forms except ``SUBSET`` + and BigQuery options. Its documented alternation-before-concatenation + precedence is used for the pattern AST. Other presets use + concatenation-before-alternation. Rendering adds explicit parentheses at + mixed operator boundaries; it does not translate expression syntax between DBs. + +Validation checks the ``matchRecognize`` and ``matchRecognizeOptions`` features. +With an explicit dialect, it also checks selected static rules: pattern variable +names, bounds, subset/skip targets and incompatible clause options. Table +finding does not treat pattern qualifiers such as ``A.price`` as table names. +Metadata validation does not bind these columns to the input relation. +Function eligibility, aggregation/type rules, data-dependent skip failures and +query execution remain the database's responsibility. + +The test fixtures were compared before and after deparsing using GoogleSQL's +reference evaluator and Oracle 26ai Free. The reference evaluator is not the +BigQuery service. Snowflake coverage follows its documentation and parser +round-trip tests; it has not been executed against a Snowflake account. + +See the official `BigQuery MATCH_RECOGNIZE syntax +`_, +`Oracle row pattern matching guide +`_ +and `Snowflake MATCH_RECOGNIZE reference +`_. + The individual features ------------------------------ diff --git a/src/test/java/net/sf/jsqlparser/statement/select/MatchRecognizeDialectTest.java b/src/test/java/net/sf/jsqlparser/statement/select/MatchRecognizeDialectTest.java new file mode 100644 index 000000000..794b1abe6 --- /dev/null +++ b/src/test/java/net/sf/jsqlparser/statement/select/MatchRecognizeDialectTest.java @@ -0,0 +1,210 @@ +/*- + * #%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 org.junit.jupiter.api.Assertions.*; + +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Stream; +import net.sf.jsqlparser.expression.ExpressionVisitorAdapter; +import net.sf.jsqlparser.expression.LongValue; +import net.sf.jsqlparser.expression.RowPatternFunction; +import net.sf.jsqlparser.expression.operators.arithmetic.Addition; +import net.sf.jsqlparser.parser.AbstractJSqlParser.Dialect; +import net.sf.jsqlparser.parser.CCJSqlParserUtil; +import net.sf.jsqlparser.schema.Column; +import net.sf.jsqlparser.statement.StatementFeatureVisitor; +import net.sf.jsqlparser.util.TablesNamesFinder; +import net.sf.jsqlparser.util.deparser.ExpressionDeParser; +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.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.junit.jupiter.params.provider.ValueSource; + +class MatchRecognizeDialectTest { + private static String read(String name) throws Exception { + try (InputStream input = MatchRecognizeDialectTest.class.getResourceAsStream( + "/net/sf/jsqlparser/statement/select/match-recognize/" + name)) { + assertNotNull(input, name); + return new String(input.readAllBytes(), StandardCharsets.UTF_8); + } + } + + static Stream executedCases() throws Exception { + return read("execution-cases.txt").lines().map(line -> line.split(" ")) + .map(parts -> Arguments.of(Dialect.valueOf(parts[0]), parts[1])); + } + + private static PlainSelect parse(String sql, Dialect dialect) throws Exception { + return (PlainSelect) CCJSqlParserUtil.parse(sql, p -> p.withDialect(dialect)); + } + + private static String deparse(PlainSelect select) { + StringBuilder builder = new StringBuilder(); + select.accept(new StatementDeParser(builder)); + return builder.toString(); + } + + private static PlainSelect roundTrip(String sql, Dialect dialect) throws Exception { + PlainSelect select = parse(sql, dialect); + assertEquals(select.toString(), deparse(select)); + assertEquals(select.toString(), parse(deparse(select), dialect).toString()); + return select; + } + + @ParameterizedTest(name = "{0}: {1}") + @MethodSource("executedCases") + void executionFixturesRoundTrip(Dialect dialect, String file) throws Exception { + roundTrip(read(file), dialect); + } + + private static String snowflake(String clause) { + return "SELECT * FROM events MATCH_RECOGNIZE (" + clause + ")"; + } + + @ParameterizedTest + @ValueSource(strings = { + "PATTERN (A+) DEFINE A AS price > 0", + "ALL ROWS PER MATCH PATTERN (A+) DEFINE A AS price > 0", + "MEASURES MATCH_SEQUENCE_NUMBER() AS n ALL ROWS PER MATCH PATTERN (A B) DEFINE A AS price > 0", + "MEASURES FINAL LAST(A.price) AS p ALL ROWS PER MATCH OMIT EMPTY MATCHES PATTERN (A*) DEFINE A AS price > 0", + "ALL ROWS PER MATCH SHOW EMPTY MATCHES PATTERN (A*) DEFINE A AS price > 0", + "ALL ROWS PER MATCH WITH UNMATCHED ROWS PATTERN (A+) DEFINE A AS price > 0", + "AFTER MATCH SKIP TO FIRST B PATTERN (A B+) DEFINE A AS price > 0, B AS price < 0", + "AFTER MATCH SKIP TO LAST B PATTERN (A B+) DEFINE A AS price > 0, B AS price < 0", + "AFTER MATCH SKIP TO B PATTERN (A B+) DEFINE A AS price > 0, B AS price < 0", + "PATTERN (PERMUTE(A, B){1,2}) DEFINE A AS price > 0, B AS price < 0", + "PATTERN ({- A+ -} B) DEFINE A AS price > 0, B AS price < 0", + "PATTERN (^ A B | C $) DEFINE A AS price > 0, B AS price = 0, C AS price < 0", + "PATTERN (A+) DEFINE A AS price > LAG(price)", + "MEASURES RUNNING SUM(A.price) + FINAL SUM(A.price) AS total PATTERN (A+) DEFINE A AS price > 0" + }) + void documentedSnowflakeSyntax(String clause) throws Exception { + roundTrip(snowflake(clause), Dialect.SNOWFLAKE); + } + + @Test + void snowflakeDocumentedPrecedenceIsExplicitInTheAst() throws Exception { + String query = snowflake("PATTERN (A B | C) DEFINE A AS price > 0"); + MatchRecognize node = (MatchRecognize) roundTrip(query, Dialect.SNOWFLAKE).getFromItem(); + RowPattern.Operation sequence = (RowPattern.Operation) node.getPattern(); + assertEquals(RowPattern.Operation.Type.SEQUENCE, sequence.getType()); + RowPattern.Operation alternative = (RowPattern.Operation) sequence.getPatterns().get(1); + assertEquals(RowPattern.Operation.Type.ALTERNATION, alternative.getType()); + assertEquals("A (B | C)", sequence.toString()); + MatchRecognize oracle = (MatchRecognize) parse(query, Dialect.ORACLE).getFromItem(); + assertEquals(RowPattern.Operation.Type.ALTERNATION, + ((RowPattern.Operation) oracle.getPattern()).getType()); + assertEquals("(A B) | C", oracle.getPattern().toString()); + } + + @Test + void prefixFunctionsRemainEditableAndVisibleToExistingVisitors() throws Exception { + PlainSelect statement = roundTrip(read("oracle-running-final.sql"), Dialect.ORACLE); + MatchRecognize node = (MatchRecognize) statement.getFromItem(); + Addition sum = (Addition) node.getMeasures().get(0).getExpression(); + RowPatternFunction running = (RowPatternFunction) sum.getLeftExpression(); + RowPatternFunction finalFunction = (RowPatternFunction) sum.getRightExpression(); + assertEquals(RowPatternFunction.EvaluationMode.RUNNING, running.getEvaluationMode()); + assertEquals(RowPatternFunction.EvaluationMode.FINAL, finalFunction.getEvaluationMode()); + running.getFunction().setName("custom_measure"); + running.getFunction().setParameters( + new net.sf.jsqlparser.expression.operators.relational.ExpressionList<>( + new LongValue(42))); + assertTrue(deparse(statement).contains("RUNNING custom_measure(42)")); + assertEquals(statement.toString(), deparse(statement)); + assertTrue(StatementFeatureVisitor.analyse(statement).getUnresolvedReferences() + .contains("custom_measure")); + List columns = new ArrayList<>(); + finalFunction.accept(new ExpressionVisitorAdapter() { + @Override + public Void visit(Column column, S context) { + assertEquals("context", context); + columns.add(column.toString()); + return null; + } + }, "context"); + assertEquals(List.of("A.price"), columns); + StringBuilder builder = new StringBuilder(); + finalFunction.accept(new ExpressionDeParser(null, builder) { + @Override + public StringBuilder visit(Column column, S context) { + return getBuilder().append("A.changed"); + } + }, null); + assertEquals("FINAL SUM(A.changed)", builder.toString()); + } + + @Test + void subsetNamesAreNotDiscoveredAsTables() throws Exception { + String query = read("oracle-subset.sql"); + net.sf.jsqlparser.expression.Expression expression = + CCJSqlParserUtil.parseExpression("(" + query.replace(";", "") + ")"); + assertFalse(new TablesNamesFinder().getTables(expression).contains("U")); + MatchRecognize node = (MatchRecognize) parse(query, Dialect.ORACLE).getFromItem(); + assertEquals("U", node.getSubsets().get(0).getName()); + assertEquals(List.of("A", "B"), node.getSubsets().get(0).getVariables()); + } + + @Test + void permutationAndExclusionVisitorsKeepStructureWithoutExpansion() throws Exception { + PlainSelect statement = + roundTrip(snowflake("PATTERN (PERMUTE(A, {- B{2} -}, C)) DEFINE A AS price > 0"), + Dialect.ORACLE); + MatchRecognize node = (MatchRecognize) statement.getFromItem(); + RowPattern.Permute permute = (RowPattern.Permute) node.getPattern(); + assertEquals(3, permute.getPatterns().size()); + assertInstanceOf(RowPattern.Exclusion.class, permute.getPatterns().get(1)); + List variables = new ArrayList<>(); + permute.accept(new RowPatternVisitorAdapter() { + @Override + public Void visit(RowPattern.Variable variable, S context) { + assertEquals("pattern", context); + variables.add(variable.getName()); + return null; + } + }, "pattern"); + assertEquals(List.of("A", "B", "C"), variables); + permute.getPatterns().set(2, new RowPattern.Variable("D")); + assertTrue(deparse(statement).contains("PERMUTE(A, {- B{2} -}, D)")); + } + + @Test + void outputPivotAliasAndSampleStayAfterMatchRecognize() throws Exception { + String query = snowflake( + "MEASURES CLASSIFIER() AS label, SUM(A.price) AS total ALL ROWS PER MATCH PATTERN (A+) DEFINE A AS price > 0") + + " PIVOT (SUM(total) FOR label IN ('A')) p SAMPLE (10)"; + PlainSelect statement = roundTrip(query, Dialect.SNOWFLAKE); + MatchRecognize node = (MatchRecognize) statement.getFromItem(); + assertNotNull(node.getPivot()); + assertNotNull(node.getSampleClause()); + List columns = new ArrayList<>(); + node.accept(new FromItemVisitorAdapter(new ExpressionVisitorAdapter() { + @Override + public Void visit(Column column, S context) { + columns.add(column.toString()); + return null; + } + }), null); + assertTrue(columns.containsAll(List.of("total", "label", "A.price"))); + } + + @ParameterizedTest + @ValueSource(strings = {"SELECT running, \"final\" FROM t", "SELECT running(price) FROM t", + "SELECT * FROM t FINAL", "SELECT 'RUNNING SUM(x)' FROM t"}) + void prefixWordsKeepExistingUses(String sql) throws Exception { + roundTrip(sql, Dialect.ORACLE); + } +} diff --git a/src/test/java/net/sf/jsqlparser/statement/select/MatchRecognizeTest.java b/src/test/java/net/sf/jsqlparser/statement/select/MatchRecognizeTest.java new file mode 100644 index 000000000..b6c952b87 --- /dev/null +++ b/src/test/java/net/sf/jsqlparser/statement/select/MatchRecognizeTest.java @@ -0,0 +1,300 @@ +/*- + * #%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 org.junit.jupiter.api.Assertions.*; + +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import net.sf.jsqlparser.JSQLParserException; +import net.sf.jsqlparser.expression.ExpressionVisitorAdapter; +import net.sf.jsqlparser.expression.JdbcParameter; +import net.sf.jsqlparser.expression.LongValue; +import net.sf.jsqlparser.parser.AbstractJSqlParser.Dialect; +import net.sf.jsqlparser.parser.CCJSqlParserUtil; +import net.sf.jsqlparser.schema.Column; +import net.sf.jsqlparser.schema.Table; +import net.sf.jsqlparser.statement.StatementFeatureVisitor; +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 org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +class MatchRecognizeTest { + private static String sql(String pattern) { + return "SELECT * FROM events MATCH_RECOGNIZE (PARTITION BY account_id " + + "ORDER BY seq DESC NULLS LAST MEASURES SUM(A.price) AS total, COUNT(*) AS n " + + "PATTERN (" + pattern + + ") DEFINE A AS price > 0, B AS price = 0, C AS price < 0)"; + } + + private static PlainSelect parse(String sql) throws JSQLParserException { + return (PlainSelect) CCJSqlParserUtil.parse(sql, p -> p.withDialect(Dialect.BIGQUERY)); + } + + private static MatchRecognize match(PlainSelect select) { + return assertInstanceOf(MatchRecognize.class, select.getFromItem()); + } + + private static String deparse(Select select) { + StringBuilder builder = new StringBuilder(); + select.accept(new StatementDeParser(builder)); + return builder.toString(); + } + + private static PlainSelect roundTrip(String sql) throws JSQLParserException { + PlainSelect statement = parse(sql); + String rendered = deparse(statement); + assertEquals(statement.toString(), rendered); + assertEquals(rendered, parse(rendered).toString()); + return statement; + } + + @Test + void reportedQuery() throws Exception { + String input; + try (java.io.InputStream stream = getClass().getResourceAsStream( + "/net/sf/jsqlparser/statement/select/match-recognize-2350.sql")) { + assertNotNull(stream); + input = new String(stream.readAllBytes(), StandardCharsets.UTF_8); + } + MatchRecognize match = match(roundTrip(input)); + assertEquals(4, match.getMeasures().size()); + assertEquals(3, match.getDefinitions().size()); + assertEquals(Boolean.FALSE, match.getOptions().getUseLongestMatch()); + assertEquals(MatchRecognize.SkipMode.PAST_LAST_ROW, match.getSkipMode()); + assertEquals("low mid+ high+", match.getPattern().toString()); + } + + @ParameterizedTest + @ValueSource(strings = {"A", "A B | C", "(A B | C)+?", "A??", "A*?", "A+?", + "A{3}", "A{2,}", "A{,4}?", "A{2,4}?", "A{@lo,@hi}", "A{?,?}", + "A ()", "| A", "A |", "A||B", "||A||", "^ A+ $", "^A+$$", "A$", "(^)* A", "`$` A", + "(A?)?"}) + void patternRoundTrip(String pattern) throws Exception { + roundTrip(sql(pattern)); + } + + @Test + void precedenceAndMutation() throws Exception { + MatchRecognize match = match(roundTrip(sql("A B | C"))); + RowPattern.Operation alternative = + assertInstanceOf(RowPattern.Operation.class, match.getPattern()); + assertEquals(RowPattern.Operation.Type.ALTERNATION, alternative.getType()); + RowPattern.Operation sequence = + assertInstanceOf(RowPattern.Operation.class, alternative.getPatterns().get(0)); + assertEquals(RowPattern.Operation.Type.SEQUENCE, sequence.getType()); + assertEquals("A", ((RowPattern.Variable) sequence.getPatterns().get(0)).getName()); + sequence.getPatterns().set(1, + new RowPattern.Operation(RowPattern.Operation.Type.ALTERNATION, + List.of(new RowPattern.Variable("B"), new RowPattern.Variable("C")))); + assertEquals("(A (B | C)) | C", match.getPattern().toString()); + roundTrip("SELECT * FROM " + match); + } + + @ParameterizedTest + @ValueSource(strings = {"", " OPTIONS ()", " OPTIONS (use_longest_match = FALSE)", + " OPTIONS (use_longest_match = TRUE)"}) + void optionsPreservePresence(String option) throws Exception { + String query = sql("A"); + MatchRecognize match = + match(roundTrip(query.substring(0, query.length() - 1) + option + ")")); + if (option.isEmpty()) { + assertNull(match.getOptions()); + } else if (option.equals(" OPTIONS ()")) { + assertNull(match.getOptions().getUseLongestMatch()); + } else { + assertEquals(option.contains("TRUE"), match.getOptions().getUseLongestMatch()); + } + } + + @ParameterizedTest + @ValueSource(strings = {"PAST LAST ROW", "TO NEXT ROW"}) + void skip(String skip) throws Exception { + roundTrip(sql("A+").replace(" PATTERN", " AFTER MATCH SKIP " + skip + " PATTERN")); + } + + @Test + void aliasesAndJoinsBindToTheirOwnInput() throws Exception { + String query = sql("A").replace("FROM events", "FROM events AS src") + " AS result"; + MatchRecognize node = match(roundTrip(query)); + assertEquals("src", node.getInput().getAlias().getName()); + assertEquals("result", node.getAlias().getName()); + PlainSelect join = roundTrip(query.replace("FROM events", "FROM accounts x JOIN events") + + " ON x.id = result.n"); + assertInstanceOf(Table.class, join.getFromItem()); + assertInstanceOf(MatchRecognize.class, join.getJoins().get(0).getFromItem()); + node = match(roundTrip(sql("A").replace("events", + "(events JOIN accounts ON events.account_id = accounts.id)"))); + assertInstanceOf(ParenthesedFromItem.class, node.getInput()); + List inputTables = new ArrayList<>(); + node.accept(new FromItemVisitorAdapter() { + @Override + public Void visit(Table table, S context) { + inputTables.add(table.getName()); + return null; + } + }, null); + assertEquals(List.of("events", "accounts"), inputTables); + } + + @Test + void nestedSetOperationLookaheadStillLexesAnchors() throws Exception { + String nested = "SELECT * FROM ((" + sql("^A+$$") + ") UNION ALL (" + sql("A??") + ")) u"; + roundTrip(nested); + } + + @Test + void parameterIndicesExcludeQuantifiers() throws Exception { + PlainSelect select = roundTrip("SELECT ? AS start_param FROM events MATCH_RECOGNIZE (" + + "ORDER BY seq MEASURES COUNT(*) AS n PATTERN (A?? B{?}) " + + "DEFINE A AS price > ?, B AS price < 0) WHERE n > ?"); + MatchRecognize node = match(select); + List indices = new ArrayList<>(); + node.forEachExpression( + expression -> expression.accept(new ExpressionVisitorAdapter() { + @Override + public Void visit(JdbcParameter parameter, S context) { + indices.add(parameter.getIndex()); + return null; + } + }, null)); + assertEquals(List.of(2, 3), indices); + RowPattern.Quantified bound = + (RowPattern.Quantified) ((RowPattern.Operation) node.getPattern()).getPatterns() + .get(1); + bound.setLowerBound(new LongValue(4)); + assertTrue(deparse(select).contains("B{4}")); + assertEquals(1, ((JdbcParameter) select.getSelectItem(0).getExpression()).getIndex()); + } + + @Test + void expressionAndSourceCallbacksPreserveContext() throws Exception { + PlainSelect statement = parse(sql("A{@lo,3}")); + List columns = new ArrayList<>(); + Object marker = new Object(); + MatchRecognize node = match(statement); + node.accept(new FromItemVisitorAdapter(new ExpressionVisitorAdapter() { + @Override + public Void visit(Column column, S context) { + assertSame(marker, context); + columns.add(column.toString()); + return null; + } + }) { + @Override + public Void visit(Table table, S context) { + assertSame(marker, context); + columns.add("table:" + table.getName()); + return null; + } + }, marker); + assertEquals( + List.of("table:events", "account_id", "seq", "A.price", "price", "price", "price"), + columns); + StringBuilder builder = new StringBuilder(); + ExpressionDeParser expressions = new ExpressionDeParser() { + @Override + public StringBuilder visit(Column column, S context) { + return getBuilder().append("renamed_").append(column.getColumnName()); + } + }; + expressions.setBuilder(builder); + SelectDeParser selects = new SelectDeParser(expressions, builder); + expressions.setSelectVisitor(selects); + statement.accept((SelectVisitor) selects, marker); + assertTrue(builder.toString().contains("SUM(renamed_price)")); + assertTrue(builder.toString().contains("DEFINE A AS renamed_price > 0")); + assertTrue(builder.toString().contains("ORDER BY renamed_seq DESC NULLS LAST")); + } + + @Test + void discoveryAndFeatureAnalysisReachClauseExpressions() throws Exception { + PlainSelect statement = parse(sql("A").replace("SUM(A.price)", "custom_measure(A.price)") + .replace("A AS price > 0", + "A AS custom_predicate(price) > (SELECT min(limit_value) FROM limits)")); + assertEquals(Set.of("events", "limits"), new TablesNamesFinder() + .getTables((net.sf.jsqlparser.statement.Statement) statement)); + assertTrue(StatementFeatureVisitor.analyse(statement).getUnresolvedReferences() + .containsAll(Set.of("custom_measure", "custom_predicate", "min"))); + } + + @ParameterizedTest + @ValueSource(strings = {"SELECT match_recognize FROM t", "SELECT * FROM t match_recognize", + "SELECT * FROM match_recognize", "SELECT match_recognize(price) FROM t", + "SELECT pattern('$$') FROM t", "SELECT $$body$$", "SELECT 'A' || 'B' FROM t", + "SELECT * FROM t WHERE price > ?"}) + void existingNamesAndLiterals(String sql) throws Exception { + roundTrip(sql); + } + + @ParameterizedTest + @ValueSource(strings = {"A{3}?", "^+ A", "A{1+2}", "A{2,3,4}", "A{}", "A{,}", "A**", "{- A }", + "A+++"}) + void rejectsMalformedPatterns(String pattern) { + assertThrows(JSQLParserException.class, () -> parse(sql(pattern))); + } + + @Test + void expressionTableDiscoveryKeepsNestedScopes() throws Exception { + String query = sql("A").replace("SUM(A.price)", + "SUM(A.price) + (SELECT external.price FROM lookup external)"); + net.sf.jsqlparser.expression.Expression expression = + CCJSqlParserUtil.parseExpression("(" + query + ")"); + assertEquals(Set.of("events", "lookup", "external"), + new TablesNamesFinder().getTables(expression)); + } + + @Test + void inputUnionVisitorDoesNotRequirePlainSelect() throws Exception { + MatchRecognize node = match(roundTrip(sql("A").replace("FROM events", + "FROM (SELECT * FROM events UNION ALL SELECT * FROM archive_events)"))); + List tables = new ArrayList<>(); + FromItemVisitorAdapter visitor = new FromItemVisitorAdapter() { + @Override + public Void visit(Table table, S context) { + tables.add(table.getName()); + return null; + } + }; + SelectVisitorAdapter selects = + new SelectVisitorAdapter<>(new ExpressionVisitorAdapter<>(), visitor); + visitor.setSelectVisitor(selects); + node.accept(visitor, null); + assertEquals(List.of("events", "archive_events"), tables); + } + + @Test + void nestedStructRendersEditedExpressionsInsteadOfOriginalTokens() throws Exception { + PlainSelect statement = roundTrip(sql("A").replace("SUM(A.price)", + "ARRAY_AGG(STRUCT(MATCH_ROW_NUMBER() AS row_no, CLASSIFIER() AS label, A.price))")); + net.sf.jsqlparser.expression.Function array = + (net.sf.jsqlparser.expression.Function) match(statement).getMeasures().get(0) + .getExpression(); + net.sf.jsqlparser.expression.StructType struct = + (net.sf.jsqlparser.expression.StructType) array.getParameters().get(0); + struct.getArguments().set(2, new SelectItem<>(new LongValue(99))); + assertTrue(statement.toString().contains("99")); + assertFalse(statement.toString().contains("A.price")); + assertEquals(statement.toString(), deparse(statement)); + roundTrip(statement.toString()); + } + + @Test + void boundsRespectNestingLimit() { + String nested = "(".repeat(12) + "A" + ")".repeat(12); + assertThrows(JSQLParserException.class, () -> parse(sql(nested))); + } +} diff --git a/src/test/java/net/sf/jsqlparser/util/validation/validator/MatchRecognizeValidatorTest.java b/src/test/java/net/sf/jsqlparser/util/validation/validator/MatchRecognizeValidatorTest.java new file mode 100644 index 000000000..03f786d28 --- /dev/null +++ b/src/test/java/net/sf/jsqlparser/util/validation/validator/MatchRecognizeValidatorTest.java @@ -0,0 +1,169 @@ +/*- + * #%L + * JSQLParser library + * %% + * Copyright (C) 2004 - 2026 JSQLParser + * %% + * Dual licensed under GNU LGPL 2.1 or Apache License 2.0 + * #L% + */ +package net.sf.jsqlparser.util.validation.validator; + +import static org.junit.jupiter.api.Assertions.*; +import java.util.ArrayList; +import java.util.List; +import net.sf.jsqlparser.parser.feature.Feature; +import net.sf.jsqlparser.parser.feature.FeatureConfiguration; +import net.sf.jsqlparser.util.validation.Validation; +import net.sf.jsqlparser.util.validation.ValidationError; +import net.sf.jsqlparser.util.validation.feature.FeaturesAllowed; +import net.sf.jsqlparser.util.validation.metadata.DatabaseMetaDataValidation; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +class MatchRecognizeValidatorTest { + private static final String SQL = "SELECT * FROM events MATCH_RECOGNIZE (ORDER BY seq " + + "MEASURES SUM(A.price) AS total PATTERN (A+) DEFINE A AS price > 0)"; + + private static FeatureConfiguration configuration() { + FeatureConfiguration configuration = new FeatureConfiguration(); + configuration.setValue(Feature.dialect, "BIGQUERY"); + return configuration; + } + + @Test + void permitsSupportedFeaturesAndDoesNotResolveSymbolsAsTables() { + List names = new ArrayList<>(); + DatabaseMetaDataValidation metadata = name -> { + names.add(name.getFqn()); + return true; + }; + List errors = + new Validation(configuration(), List.of(FeaturesAllowed.SELECT, metadata), SQL) + .validate(); + assertTrue(errors.isEmpty(), errors.toString()); + assertTrue(names.contains("events")); + assertTrue(names.contains("price")); + assertFalse(names.contains("A.price")); + } + + @ParameterizedTest + @ValueSource(strings = {"A{10001}", "A{4,2}", "A B", "B", "A{@@lo}"}) + void invalidBoundsAndSymbols(String pattern) { + List errors = new Validation(configuration(), + List.of(FeaturesAllowed.SELECT), SQL.replace("A+", pattern)).validate(); + assertFalse(errors.isEmpty()); + } + + @Test + void duplicateSymbolsAreCaseInsensitive() { + String sql = + SQL.replace("DEFINE A AS price > 0", "DEFINE A AS price > 0, `a` AS price < 0"); + List errors = + new Validation(configuration(), List.of(FeaturesAllowed.SELECT), sql).validate(); + assertTrue(errors.toString().contains("Duplicate row pattern variable")); + } + + @Test + void featureCanBeDisabledIndependently() { + List errors = new Validation(configuration(), + List.of(new FeaturesAllowed("basic", Feature.select, Feature.function, + Feature.orderBy)), + SQL).validate(); + assertTrue(errors.toString().contains("matchRecognize")); + } + + private static List validate(String dialect, String sql) { + FeatureConfiguration configuration = new FeatureConfiguration(); + configuration.setValue(Feature.dialect, dialect); + return new Validation(configuration, List.of(FeaturesAllowed.SELECT), sql).validate(); + } + + @ParameterizedTest + @ValueSource( + strings = {"A{0}", "A{0,0}", "A{3,1}", "A{@lo}", "| A", "A |"}) + void oracleRejectsInvalidBoundsAndEmptyAlternatives(String pattern) { + List errors = validate("ORACLE", SQL.replace("A+", pattern)); + assertFalse(errors.isEmpty(), pattern); + } + + @Test + void oracleAllowsImplicitVariablesAndPreservesQuotedCase() { + assertTrue(validate("ORACLE", SQL.replace("A+", "A B+")).isEmpty()); + String caseSensitive = SQL.replace("A+", "A \"a\"").replace("DEFINE A AS price > 0", + "DEFINE A AS price > 0, \"a\" AS price < 0"); + assertTrue(validate("ORACLE", caseSensitive).isEmpty()); + assertFalse(validate("ORACLE", caseSensitive.replace("\"a\" AS", "\"A\" AS")).isEmpty()); + } + + @Test + void validatesSubsetAndSkipTargets() { + String base = SQL.replace("A+", "A B+").replace("DEFINE", "SUBSET U = (A, B) DEFINE"); + assertTrue(validate("ORACLE", base).isEmpty()); + assertFalse(validate("ORACLE", base.replace("U = (A, B)", "U = (A, C)")).isEmpty()); + assertFalse(validate("ORACLE", base.replace("U = (A, B)", "A = (A, B)")).isEmpty()); + assertTrue(validate("ORACLE", base.replace("PATTERN", "AFTER MATCH SKIP TO LAST B PATTERN")) + .isEmpty()); + assertFalse(validate("ORACLE", base.replace("PATTERN", "AFTER MATCH SKIP TO C PATTERN")) + .isEmpty()); + assertFalse(validate("SNOWFLAKE", base).isEmpty()); + } + + @Test + void exclusionCannotReturnUnmatchedRows() { + String query = SQL.replace("PATTERN (A+)", + "ALL ROWS PER MATCH WITH UNMATCHED ROWS PATTERN ({- A+ -})"); + for (String dialect : List.of("ORACLE", "SNOWFLAKE")) { + List errors = validate(dialect, query); + assertTrue(errors.toString().contains("exclusion cannot be combined"), + errors.toString()); + } + } + + @Test + void validatesDialectSpecificOptionsAndFunctionModes() { + String options = + SQL.substring(0, SQL.length() - 1) + " OPTIONS (use_longest_match = TRUE))"; + assertFalse(validate("ORACLE", options).isEmpty()); + assertFalse(validate("SNOWFLAKE", options).isEmpty()); + assertFalse(validate("BIGQUERY", SQL.replace("SUM(A.price)", "RUNNING SUM(A.price)")) + .isEmpty()); + assertFalse(validate("ORACLE", + SQL.replace("DEFINE A AS price > 0", "DEFINE A AS FINAL SUM(price) > 0")) + .isEmpty()); + assertTrue(validate("ORACLE", + SQL.replace("DEFINE A AS price > 0", "DEFINE A AS RUNNING SUM(price) > 0")) + .isEmpty()); + } + + @Test + void inputAliasScopeAndNestedQueriesStillReachMetadataValidation() { + List names = new ArrayList<>(); + DatabaseMetaDataValidation metadata = name -> { + names.add(name.getFqn()); + return true; + }; + String query = SQL.replace("FROM events", "FROM events A") + .replace("ORDER BY seq", "ORDER BY A.seq") + .replace("SUM(A.price)", + "SUM(A.price) + (SELECT 1 FROM lookup A WHERE A.nested_value > 0)"); + List errors = + new Validation(configuration(), List.of(FeaturesAllowed.SELECT, metadata), query) + .validate(); + assertTrue(errors.isEmpty(), errors.toString()); + assertTrue(names.contains("A.seq"), names.toString()); + assertTrue(names.contains("lookup"), names.toString()); + assertTrue(names.contains("A.nested_value"), names.toString()); + } + + @Test + void oracleFeatureSetRecognizesTheNewClause() { + FeatureConfiguration configuration = new FeatureConfiguration(); + configuration.setValue(Feature.dialect, "ORACLE"); + List errors = new Validation(configuration, + List.of(net.sf.jsqlparser.util.validation.feature.OracleVersion.V19C), SQL) + .validate(); + assertTrue(errors.isEmpty(), errors.toString()); + } +} diff --git a/src/test/resources/net/sf/jsqlparser/statement/select/match-recognize-2350.sql b/src/test/resources/net/sf/jsqlparser/statement/select/match-recognize-2350.sql new file mode 100644 index 000000000..916753a6e --- /dev/null +++ b/src/test/resources/net/sf/jsqlparser/statement/select/match-recognize-2350.sql @@ -0,0 +1,38 @@ +/*- + * #%L + * JSQLParser library + * %% + * Copyright (C) 2004 - 2026 JSQLParser + * %% + * Dual licensed under GNU LGPL 2.1 or Apache License 2.0 + * #L% + */ +WITH Operations AS ( + SELECT 1 AS OperationID, 120.00 AS Amount, 'C001' AS CatalogID, + DATE '2025-01-03' AS OperationDate UNION ALL + SELECT 2, 20.00, 'C001', DATE '2025-01-04' UNION ALL + SELECT 3, 175.00, 'C001', DATE '2025-01-05' UNION ALL + SELECT 4, 30.00, 'C001', DATE '2025-01-10' UNION ALL + SELECT 5, 190.00, 'C001', DATE '2025-01-11' UNION ALL + SELECT 6, 250.00, 'C001', DATE '2025-01-12' +) + +SELECT * +FROM Operations +MATCH_RECOGNIZE ( + PARTITION BY CatalogID + ORDER BY OperationDate ASC + MEASURES + FIRST(OperationDate) AS START_DT, + LAST(OperationDate) AS END_DT, + SUM(Amount) AS TOTAL_AMOUNT, + COUNT(*) AS ROW_COUNT + AFTER MATCH SKIP PAST LAST ROW + PATTERN (low mid+ high+) + DEFINE + low AS Amount < 50, + mid AS Amount between 100 and 200, + high AS Amount > 200 + OPTIONS ( use_longest_match = FALSE ) +) +ORDER BY CatalogID, START_DT; diff --git a/src/test/resources/net/sf/jsqlparser/statement/select/match-recognize/README.md b/src/test/resources/net/sf/jsqlparser/statement/select/match-recognize/README.md new file mode 100644 index 000000000..bf844489a --- /dev/null +++ b/src/test/resources/net/sf/jsqlparser/statement/select/match-recognize/README.md @@ -0,0 +1,36 @@ +These self-contained fixtures cover MATCH_RECOGNIZE parsing, AST rendering and +reparsing. `MatchRecognizeDialectTest.executionFixturesRoundTrip` reads the files +listed in `execution-cases.txt`; unit tests do not require a database connection. + +The following external checks were run on 2026-09-13. For each fixture, both the +original SQL and SQL emitted by `StatementDeParser` executed successfully and +returned identical output: + +| Fixture prefix | Executor | Cases | +| --- | --- | ---: | +| `bigquery-` | GoogleSQL reference evaluator, release `2026.9.1` | 15 | +| `oracle-` | Oracle AI Database 26ai Free, version `23.26.2.0.0` | 17 | + +GoogleSQL is the reference evaluator, not the BigQuery service. Snowflake was not +available for execution; its documented syntax and precedence are covered by +separate parser/AST tests. Function eligibility, types, aggregation rules and +data-dependent errors are outside the parser's static validation. + +The BigQuery cases exercise greedy/reluctant matching, overlap, longest-match +options, empty alternatives, adjacent anchors, parameter bounds, nested STRUCT +measures, aliases and the query reported in issue #2350. The Oracle cases cover +row output modes, empty/unmatched rows, SUBSET, PERMUTE, exclusion, RUNNING/FINAL, +skip targets, result aliases, empty groups and fixed reluctant quantifiers. + +To reproduce execution, use the [official GoogleSQL executable](https://github.com/google/googlesql/releases/tag/2026.9.1) +with `--output_mode=json --sql_file=`. For `bigquery-parameter-bound.sql`, +also pass `--parameters='lo=1;hi=2'`. The macOS executable used here has SHA-256 +`4237e700ddedbdc331bda4f2715ba97879b5fe4ab5bfdfd8812dbcc8cfbfc7c9`. +For Oracle, run each fixture through SQL*Plus with `WHENEVER SQLERROR EXIT SQL.SQLCODE`. +The local Oracle image was `ghcr.io/gvenzl/oracle-free:23.26.2-slim`, digest +`sha256:6c6b2555e19b81beb79f3429ac34f241b196785c0137125b871f0af8507685b1`. + +Render the same statement through `StatementDeParser`, using the corresponding +`Dialect.BIGQUERY` or `Dialect.ORACLE`, and run that SQL with the same parameters +and data. Compare the original and rendered outputs. The Java tests additionally +check `toString()`/deparser agreement and stable output after reparsing. diff --git a/src/test/resources/net/sf/jsqlparser/statement/select/match-recognize/bigquery-adjacent-anchors.sql b/src/test/resources/net/sf/jsqlparser/statement/select/match-recognize/bigquery-adjacent-anchors.sql new file mode 100644 index 000000000..1766c2c2a --- /dev/null +++ b/src/test/resources/net/sf/jsqlparser/statement/select/match-recognize/bigquery-adjacent-anchors.sql @@ -0,0 +1,11 @@ +/*- + * #%L + * JSQLParser library + * %% + * Copyright (C) 2004 - 2026 JSQLParser + * %% + * Dual licensed under GNU LGPL 2.1 or Apache License 2.0 + * #L% + */ +WITH events AS (SELECT 1 AS seq, 10 AS price UNION ALL SELECT 2, 20 UNION ALL SELECT 3, 30 UNION ALL SELECT 4, -5) +SELECT * FROM events MATCH_RECOGNIZE (ORDER BY seq MEASURES FIRST(seq) AS first_seq, LAST(seq) AS last_seq, COUNT(*) AS n PATTERN (^A+$) DEFINE A AS price != 0 ) ORDER BY first_seq, last_seq; diff --git a/src/test/resources/net/sf/jsqlparser/statement/select/match-recognize/bigquery-baseline.sql b/src/test/resources/net/sf/jsqlparser/statement/select/match-recognize/bigquery-baseline.sql new file mode 100644 index 000000000..cda877279 --- /dev/null +++ b/src/test/resources/net/sf/jsqlparser/statement/select/match-recognize/bigquery-baseline.sql @@ -0,0 +1,11 @@ +/*- + * #%L + * JSQLParser library + * %% + * Copyright (C) 2004 - 2026 JSQLParser + * %% + * Dual licensed under GNU LGPL 2.1 or Apache License 2.0 + * #L% + */ +WITH events AS (SELECT 1 AS seq, 10 AS price UNION ALL SELECT 2, 20 UNION ALL SELECT 3, 30 UNION ALL SELECT 4, -5) +SELECT * FROM events MATCH_RECOGNIZE (ORDER BY seq MEASURES FIRST(seq) AS first_seq, LAST(seq) AS last_seq, COUNT(*) AS n PATTERN (A+) DEFINE A AS price > 0 ) ORDER BY first_seq, last_seq; diff --git a/src/test/resources/net/sf/jsqlparser/statement/select/match-recognize/bigquery-bounds.sql b/src/test/resources/net/sf/jsqlparser/statement/select/match-recognize/bigquery-bounds.sql new file mode 100644 index 000000000..63a2406e9 --- /dev/null +++ b/src/test/resources/net/sf/jsqlparser/statement/select/match-recognize/bigquery-bounds.sql @@ -0,0 +1,11 @@ +/*- + * #%L + * JSQLParser library + * %% + * Copyright (C) 2004 - 2026 JSQLParser + * %% + * Dual licensed under GNU LGPL 2.1 or Apache License 2.0 + * #L% + */ +WITH events AS (SELECT 1 AS seq, 10 AS price UNION ALL SELECT 2, 20 UNION ALL SELECT 3, 30 UNION ALL SELECT 4, -5) +SELECT * FROM events MATCH_RECOGNIZE (ORDER BY seq MEASURES FIRST(seq) AS first_seq, LAST(seq) AS last_seq, COUNT(*) AS n PATTERN (A{1,2}) DEFINE A AS price > 0 ) ORDER BY first_seq, last_seq; diff --git a/src/test/resources/net/sf/jsqlparser/statement/select/match-recognize/bigquery-empty-alternative.sql b/src/test/resources/net/sf/jsqlparser/statement/select/match-recognize/bigquery-empty-alternative.sql new file mode 100644 index 000000000..9b7b14690 --- /dev/null +++ b/src/test/resources/net/sf/jsqlparser/statement/select/match-recognize/bigquery-empty-alternative.sql @@ -0,0 +1,11 @@ +/*- + * #%L + * JSQLParser library + * %% + * Copyright (C) 2004 - 2026 JSQLParser + * %% + * Dual licensed under GNU LGPL 2.1 or Apache License 2.0 + * #L% + */ +WITH events AS (SELECT 1 AS seq, 10 AS price UNION ALL SELECT 2, 20 UNION ALL SELECT 3, 30 UNION ALL SELECT 4, -5) +SELECT * FROM events MATCH_RECOGNIZE (ORDER BY seq MEASURES FIRST(seq) AS first_seq, LAST(seq) AS last_seq, COUNT(*) AS n PATTERN (A|) DEFINE A AS price > 0 ) ORDER BY first_seq, last_seq; diff --git a/src/test/resources/net/sf/jsqlparser/statement/select/match-recognize/bigquery-greedy.sql b/src/test/resources/net/sf/jsqlparser/statement/select/match-recognize/bigquery-greedy.sql new file mode 100644 index 000000000..158e7b4d3 --- /dev/null +++ b/src/test/resources/net/sf/jsqlparser/statement/select/match-recognize/bigquery-greedy.sql @@ -0,0 +1,11 @@ +/*- + * #%L + * JSQLParser library + * %% + * Copyright (C) 2004 - 2026 JSQLParser + * %% + * Dual licensed under GNU LGPL 2.1 or Apache License 2.0 + * #L% + */ +WITH events AS (SELECT 1 AS seq, 10 AS price UNION ALL SELECT 2, 20 UNION ALL SELECT 3, 30 UNION ALL SELECT 4, -5) +SELECT * FROM events MATCH_RECOGNIZE (ORDER BY seq MEASURES FIRST(seq) AS first_seq, LAST(seq) AS last_seq, COUNT(*) AS n PATTERN (A+ B) DEFINE A AS price > 0, B AS price > 0 ) ORDER BY first_seq, last_seq; diff --git a/src/test/resources/net/sf/jsqlparser/statement/select/match-recognize/bigquery-issue2350.sql b/src/test/resources/net/sf/jsqlparser/statement/select/match-recognize/bigquery-issue2350.sql new file mode 100644 index 000000000..916753a6e --- /dev/null +++ b/src/test/resources/net/sf/jsqlparser/statement/select/match-recognize/bigquery-issue2350.sql @@ -0,0 +1,38 @@ +/*- + * #%L + * JSQLParser library + * %% + * Copyright (C) 2004 - 2026 JSQLParser + * %% + * Dual licensed under GNU LGPL 2.1 or Apache License 2.0 + * #L% + */ +WITH Operations AS ( + SELECT 1 AS OperationID, 120.00 AS Amount, 'C001' AS CatalogID, + DATE '2025-01-03' AS OperationDate UNION ALL + SELECT 2, 20.00, 'C001', DATE '2025-01-04' UNION ALL + SELECT 3, 175.00, 'C001', DATE '2025-01-05' UNION ALL + SELECT 4, 30.00, 'C001', DATE '2025-01-10' UNION ALL + SELECT 5, 190.00, 'C001', DATE '2025-01-11' UNION ALL + SELECT 6, 250.00, 'C001', DATE '2025-01-12' +) + +SELECT * +FROM Operations +MATCH_RECOGNIZE ( + PARTITION BY CatalogID + ORDER BY OperationDate ASC + MEASURES + FIRST(OperationDate) AS START_DT, + LAST(OperationDate) AS END_DT, + SUM(Amount) AS TOTAL_AMOUNT, + COUNT(*) AS ROW_COUNT + AFTER MATCH SKIP PAST LAST ROW + PATTERN (low mid+ high+) + DEFINE + low AS Amount < 50, + mid AS Amount between 100 and 200, + high AS Amount > 200 + OPTIONS ( use_longest_match = FALSE ) +) +ORDER BY CatalogID, START_DT; diff --git a/src/test/resources/net/sf/jsqlparser/statement/select/match-recognize/bigquery-longest-false.sql b/src/test/resources/net/sf/jsqlparser/statement/select/match-recognize/bigquery-longest-false.sql new file mode 100644 index 000000000..8a6a26b9f --- /dev/null +++ b/src/test/resources/net/sf/jsqlparser/statement/select/match-recognize/bigquery-longest-false.sql @@ -0,0 +1,11 @@ +/*- + * #%L + * JSQLParser library + * %% + * Copyright (C) 2004 - 2026 JSQLParser + * %% + * Dual licensed under GNU LGPL 2.1 or Apache License 2.0 + * #L% + */ +WITH events AS (SELECT 1 AS seq, 10 AS price UNION ALL SELECT 2, 20 UNION ALL SELECT 3, 30 UNION ALL SELECT 4, -5) +SELECT * FROM events MATCH_RECOGNIZE (ORDER BY seq MEASURES FIRST(seq) AS first_seq, LAST(seq) AS last_seq, COUNT(*) AS n PATTERN (A | A B+) DEFINE A AS price > 0, B AS price > 0 OPTIONS (use_longest_match=FALSE)) ORDER BY first_seq, last_seq; diff --git a/src/test/resources/net/sf/jsqlparser/statement/select/match-recognize/bigquery-longest-true.sql b/src/test/resources/net/sf/jsqlparser/statement/select/match-recognize/bigquery-longest-true.sql new file mode 100644 index 000000000..948f5d7ec --- /dev/null +++ b/src/test/resources/net/sf/jsqlparser/statement/select/match-recognize/bigquery-longest-true.sql @@ -0,0 +1,11 @@ +/*- + * #%L + * JSQLParser library + * %% + * Copyright (C) 2004 - 2026 JSQLParser + * %% + * Dual licensed under GNU LGPL 2.1 or Apache License 2.0 + * #L% + */ +WITH events AS (SELECT 1 AS seq, 10 AS price UNION ALL SELECT 2, 20 UNION ALL SELECT 3, 30 UNION ALL SELECT 4, -5) +SELECT * FROM events MATCH_RECOGNIZE (ORDER BY seq MEASURES FIRST(seq) AS first_seq, LAST(seq) AS last_seq, COUNT(*) AS n PATTERN (A | A B+) DEFINE A AS price > 0, B AS price > 0 OPTIONS (use_longest_match=TRUE)) ORDER BY first_seq, last_seq; diff --git a/src/test/resources/net/sf/jsqlparser/statement/select/match-recognize/bigquery-nested-struct.sql b/src/test/resources/net/sf/jsqlparser/statement/select/match-recognize/bigquery-nested-struct.sql new file mode 100644 index 000000000..198bdccd5 --- /dev/null +++ b/src/test/resources/net/sf/jsqlparser/statement/select/match-recognize/bigquery-nested-struct.sql @@ -0,0 +1,11 @@ +/*- + * #%L + * JSQLParser library + * %% + * Copyright (C) 2004 - 2026 JSQLParser + * %% + * Dual licensed under GNU LGPL 2.1 or Apache License 2.0 + * #L% + */ +WITH events AS (SELECT 1 AS seq, 10 AS price UNION ALL SELECT 2, 20 UNION ALL SELECT 3, 30 UNION ALL SELECT 4, -5) +SELECT * FROM events MATCH_RECOGNIZE (ORDER BY seq MEASURES FIRST(seq) AS first_seq, LAST(seq) AS last_seq, ARRAY_AGG(STRUCT(MATCH_ROW_NUMBER() AS row_no, CLASSIFIER() AS label, price)) AS details PATTERN (A+) DEFINE A AS price > 0 ) ORDER BY first_seq, last_seq; diff --git a/src/test/resources/net/sf/jsqlparser/statement/select/match-recognize/bigquery-options-empty.sql b/src/test/resources/net/sf/jsqlparser/statement/select/match-recognize/bigquery-options-empty.sql new file mode 100644 index 000000000..e5ade1c4c --- /dev/null +++ b/src/test/resources/net/sf/jsqlparser/statement/select/match-recognize/bigquery-options-empty.sql @@ -0,0 +1,11 @@ +/*- + * #%L + * JSQLParser library + * %% + * Copyright (C) 2004 - 2026 JSQLParser + * %% + * Dual licensed under GNU LGPL 2.1 or Apache License 2.0 + * #L% + */ +WITH events AS (SELECT 1 AS seq, 10 AS price UNION ALL SELECT 2, 20 UNION ALL SELECT 3, 30 UNION ALL SELECT 4, -5) +SELECT * FROM events MATCH_RECOGNIZE (ORDER BY seq MEASURES FIRST(seq) AS first_seq, LAST(seq) AS last_seq, COUNT(*) AS n PATTERN (A+) DEFINE A AS price > 0 OPTIONS ()) ORDER BY first_seq, last_seq; diff --git a/src/test/resources/net/sf/jsqlparser/statement/select/match-recognize/bigquery-overlap.sql b/src/test/resources/net/sf/jsqlparser/statement/select/match-recognize/bigquery-overlap.sql new file mode 100644 index 000000000..907491c87 --- /dev/null +++ b/src/test/resources/net/sf/jsqlparser/statement/select/match-recognize/bigquery-overlap.sql @@ -0,0 +1,11 @@ +/*- + * #%L + * JSQLParser library + * %% + * Copyright (C) 2004 - 2026 JSQLParser + * %% + * Dual licensed under GNU LGPL 2.1 or Apache License 2.0 + * #L% + */ +WITH events AS (SELECT 1 AS seq, 10 AS price UNION ALL SELECT 2, 20 UNION ALL SELECT 3, 30 UNION ALL SELECT 4, -5) +SELECT * FROM events MATCH_RECOGNIZE (ORDER BY seq MEASURES FIRST(seq) AS first_seq, LAST(seq) AS last_seq, COUNT(*) AS n AFTER MATCH SKIP TO NEXT ROW PATTERN (A+) DEFINE A AS price > 0 ) ORDER BY first_seq, last_seq; diff --git a/src/test/resources/net/sf/jsqlparser/statement/select/match-recognize/bigquery-parameter-bound.sql b/src/test/resources/net/sf/jsqlparser/statement/select/match-recognize/bigquery-parameter-bound.sql new file mode 100644 index 000000000..d8cb06249 --- /dev/null +++ b/src/test/resources/net/sf/jsqlparser/statement/select/match-recognize/bigquery-parameter-bound.sql @@ -0,0 +1,11 @@ +/*- + * #%L + * JSQLParser library + * %% + * Copyright (C) 2004 - 2026 JSQLParser + * %% + * Dual licensed under GNU LGPL 2.1 or Apache License 2.0 + * #L% + */ +WITH events AS (SELECT 1 AS seq, 10 AS price UNION ALL SELECT 2, 20 UNION ALL SELECT 3, 30 UNION ALL SELECT 4, -5) +SELECT * FROM events MATCH_RECOGNIZE (ORDER BY seq MEASURES FIRST(seq) AS first_seq, LAST(seq) AS last_seq, COUNT(*) AS n PATTERN (A{@lo,@hi}) DEFINE A AS price > 0 ) ORDER BY first_seq, last_seq; diff --git a/src/test/resources/net/sf/jsqlparser/statement/select/match-recognize/bigquery-reluctant.sql b/src/test/resources/net/sf/jsqlparser/statement/select/match-recognize/bigquery-reluctant.sql new file mode 100644 index 000000000..b064ba9aa --- /dev/null +++ b/src/test/resources/net/sf/jsqlparser/statement/select/match-recognize/bigquery-reluctant.sql @@ -0,0 +1,11 @@ +/*- + * #%L + * JSQLParser library + * %% + * Copyright (C) 2004 - 2026 JSQLParser + * %% + * Dual licensed under GNU LGPL 2.1 or Apache License 2.0 + * #L% + */ +WITH events AS (SELECT 1 AS seq, 10 AS price UNION ALL SELECT 2, 20 UNION ALL SELECT 3, 30 UNION ALL SELECT 4, -5) +SELECT * FROM events MATCH_RECOGNIZE (ORDER BY seq MEASURES FIRST(seq) AS first_seq, LAST(seq) AS last_seq, COUNT(*) AS n PATTERN (A+? B) DEFINE A AS price > 0, B AS price > 0 ) ORDER BY first_seq, last_seq; diff --git a/src/test/resources/net/sf/jsqlparser/statement/select/match-recognize/bigquery-repeated-anchors.sql b/src/test/resources/net/sf/jsqlparser/statement/select/match-recognize/bigquery-repeated-anchors.sql new file mode 100644 index 000000000..d99d74793 --- /dev/null +++ b/src/test/resources/net/sf/jsqlparser/statement/select/match-recognize/bigquery-repeated-anchors.sql @@ -0,0 +1,11 @@ +/*- + * #%L + * JSQLParser library + * %% + * Copyright (C) 2004 - 2026 JSQLParser + * %% + * Dual licensed under GNU LGPL 2.1 or Apache License 2.0 + * #L% + */ +WITH events AS (SELECT 1 AS seq, 10 AS price UNION ALL SELECT 2, 20 UNION ALL SELECT 3, 30 UNION ALL SELECT 4, -5) +SELECT * FROM events MATCH_RECOGNIZE (ORDER BY seq MEASURES FIRST(seq) AS first_seq, LAST(seq) AS last_seq, COUNT(*) AS n PATTERN (^A+$$) DEFINE A AS price != 0 ) ORDER BY first_seq, last_seq; diff --git a/src/test/resources/net/sf/jsqlparser/statement/select/match-recognize/bigquery-source-alias.sql b/src/test/resources/net/sf/jsqlparser/statement/select/match-recognize/bigquery-source-alias.sql new file mode 100644 index 000000000..750e1f257 --- /dev/null +++ b/src/test/resources/net/sf/jsqlparser/statement/select/match-recognize/bigquery-source-alias.sql @@ -0,0 +1,11 @@ +/*- + * #%L + * JSQLParser library + * %% + * Copyright (C) 2004 - 2026 JSQLParser + * %% + * Dual licensed under GNU LGPL 2.1 or Apache License 2.0 + * #L% + */ +WITH events AS (SELECT 1 AS seq, 10 AS price UNION ALL SELECT 2, 20 UNION ALL SELECT 3, 30 UNION ALL SELECT 4, -5) +SELECT * FROM events AS e MATCH_RECOGNIZE (ORDER BY seq MEASURES FIRST(seq) AS first_seq, LAST(seq) AS last_seq, COUNT(*) AS n PATTERN (A+) DEFINE A AS price > 0 ) AS m ORDER BY first_seq, last_seq; diff --git a/src/test/resources/net/sf/jsqlparser/statement/select/match-recognize/execution-cases.txt b/src/test/resources/net/sf/jsqlparser/statement/select/match-recognize/execution-cases.txt new file mode 100644 index 000000000..7c57d2000 --- /dev/null +++ b/src/test/resources/net/sf/jsqlparser/statement/select/match-recognize/execution-cases.txt @@ -0,0 +1,32 @@ +BIGQUERY bigquery-baseline.sql +BIGQUERY bigquery-options-empty.sql +BIGQUERY bigquery-longest-false.sql +BIGQUERY bigquery-longest-true.sql +BIGQUERY bigquery-reluctant.sql +BIGQUERY bigquery-greedy.sql +BIGQUERY bigquery-overlap.sql +BIGQUERY bigquery-bounds.sql +BIGQUERY bigquery-parameter-bound.sql +BIGQUERY bigquery-empty-alternative.sql +BIGQUERY bigquery-adjacent-anchors.sql +BIGQUERY bigquery-repeated-anchors.sql +BIGQUERY bigquery-nested-struct.sql +BIGQUERY bigquery-source-alias.sql +BIGQUERY bigquery-issue2350.sql +ORACLE oracle-baseline.sql +ORACLE oracle-one.sql +ORACLE oracle-all.sql +ORACLE oracle-show.sql +ORACLE oracle-omit.sql +ORACLE oracle-unmatched.sql +ORACLE oracle-subset.sql +ORACLE oracle-permute.sql +ORACLE oracle-exclusion.sql +ORACLE oracle-running-final.sql +ORACLE oracle-skip-first.sql +ORACLE oracle-skip-last.sql +ORACLE oracle-skip-symbol.sql +ORACLE oracle-result-alias.sql +ORACLE oracle-empty-group.sql +ORACLE oracle-fixed-reluctant.sql +ORACLE oracle-minimal.sql diff --git a/src/test/resources/net/sf/jsqlparser/statement/select/match-recognize/oracle-all.sql b/src/test/resources/net/sf/jsqlparser/statement/select/match-recognize/oracle-all.sql new file mode 100644 index 000000000..0aaa34150 --- /dev/null +++ b/src/test/resources/net/sf/jsqlparser/statement/select/match-recognize/oracle-all.sql @@ -0,0 +1,11 @@ +/*- + * #%L + * JSQLParser library + * %% + * Copyright (C) 2004 - 2026 JSQLParser + * %% + * Dual licensed under GNU LGPL 2.1 or Apache License 2.0 + * #L% + */ +WITH events AS (SELECT 1 seq, 10 price FROM dual UNION ALL SELECT 2, 20 FROM dual UNION ALL SELECT 3, -5 FROM dual UNION ALL SELECT 4, 15 FROM dual) +SELECT * FROM events MATCH_RECOGNIZE (ORDER BY seq MEASURES COUNT(*) AS n ALL ROWS PER MATCH PATTERN (A+) DEFINE A AS price > 0) ; diff --git a/src/test/resources/net/sf/jsqlparser/statement/select/match-recognize/oracle-baseline.sql b/src/test/resources/net/sf/jsqlparser/statement/select/match-recognize/oracle-baseline.sql new file mode 100644 index 000000000..c633a8388 --- /dev/null +++ b/src/test/resources/net/sf/jsqlparser/statement/select/match-recognize/oracle-baseline.sql @@ -0,0 +1,11 @@ +/*- + * #%L + * JSQLParser library + * %% + * Copyright (C) 2004 - 2026 JSQLParser + * %% + * Dual licensed under GNU LGPL 2.1 or Apache License 2.0 + * #L% + */ +WITH events AS (SELECT 1 seq, 10 price FROM dual UNION ALL SELECT 2, 20 FROM dual UNION ALL SELECT 3, -5 FROM dual UNION ALL SELECT 4, 15 FROM dual) +SELECT * FROM events MATCH_RECOGNIZE (ORDER BY seq MEASURES COUNT(*) AS n PATTERN (A+) DEFINE A AS price > 0) ; diff --git a/src/test/resources/net/sf/jsqlparser/statement/select/match-recognize/oracle-empty-group.sql b/src/test/resources/net/sf/jsqlparser/statement/select/match-recognize/oracle-empty-group.sql new file mode 100644 index 000000000..032f4e599 --- /dev/null +++ b/src/test/resources/net/sf/jsqlparser/statement/select/match-recognize/oracle-empty-group.sql @@ -0,0 +1,11 @@ +/*- + * #%L + * JSQLParser library + * %% + * Copyright (C) 2004 - 2026 JSQLParser + * %% + * Dual licensed under GNU LGPL 2.1 or Apache License 2.0 + * #L% + */ +WITH events AS (SELECT 1 seq, 10 price FROM dual UNION ALL SELECT 2, 20 FROM dual UNION ALL SELECT 3, -5 FROM dual UNION ALL SELECT 4, 15 FROM dual) +SELECT * FROM events MATCH_RECOGNIZE (ORDER BY seq MEASURES COUNT(*) AS n PATTERN (A ()) DEFINE A AS price > 0) ; diff --git a/src/test/resources/net/sf/jsqlparser/statement/select/match-recognize/oracle-exclusion.sql b/src/test/resources/net/sf/jsqlparser/statement/select/match-recognize/oracle-exclusion.sql new file mode 100644 index 000000000..aa4e925e3 --- /dev/null +++ b/src/test/resources/net/sf/jsqlparser/statement/select/match-recognize/oracle-exclusion.sql @@ -0,0 +1,11 @@ +/*- + * #%L + * JSQLParser library + * %% + * Copyright (C) 2004 - 2026 JSQLParser + * %% + * Dual licensed under GNU LGPL 2.1 or Apache License 2.0 + * #L% + */ +WITH events AS (SELECT 1 seq, 10 price FROM dual UNION ALL SELECT 2, 20 FROM dual UNION ALL SELECT 3, -5 FROM dual UNION ALL SELECT 4, 15 FROM dual) +SELECT * FROM events MATCH_RECOGNIZE (ORDER BY seq MEASURES COUNT(*) AS n ALL ROWS PER MATCH PATTERN ({- A+ -} B) DEFINE A AS price > 0, B AS price <= 0) ; diff --git a/src/test/resources/net/sf/jsqlparser/statement/select/match-recognize/oracle-fixed-reluctant.sql b/src/test/resources/net/sf/jsqlparser/statement/select/match-recognize/oracle-fixed-reluctant.sql new file mode 100644 index 000000000..8f8a94b6b --- /dev/null +++ b/src/test/resources/net/sf/jsqlparser/statement/select/match-recognize/oracle-fixed-reluctant.sql @@ -0,0 +1,11 @@ +/*- + * #%L + * JSQLParser library + * %% + * Copyright (C) 2004 - 2026 JSQLParser + * %% + * Dual licensed under GNU LGPL 2.1 or Apache License 2.0 + * #L% + */ +WITH events AS (SELECT 1 seq, 10 price FROM dual UNION ALL SELECT 2, 20 FROM dual UNION ALL SELECT 3, -5 FROM dual UNION ALL SELECT 4, 15 FROM dual) +SELECT * FROM events MATCH_RECOGNIZE (ORDER BY seq MEASURES COUNT(*) AS n PATTERN (A{1}?) DEFINE A AS price > 0) ; diff --git a/src/test/resources/net/sf/jsqlparser/statement/select/match-recognize/oracle-minimal.sql b/src/test/resources/net/sf/jsqlparser/statement/select/match-recognize/oracle-minimal.sql new file mode 100644 index 000000000..f371d1c91 --- /dev/null +++ b/src/test/resources/net/sf/jsqlparser/statement/select/match-recognize/oracle-minimal.sql @@ -0,0 +1,11 @@ +/*- + * #%L + * JSQLParser library + * %% + * Copyright (C) 2004 - 2026 JSQLParser + * %% + * Dual licensed under GNU LGPL 2.1 or Apache License 2.0 + * #L% + */ +WITH events AS (SELECT 1 seq, 10 price FROM dual UNION ALL SELECT 2, 20 FROM dual UNION ALL SELECT 3, -5 FROM dual UNION ALL SELECT 4, 15 FROM dual) +SELECT * FROM events MATCH_RECOGNIZE (ALL ROWS PER MATCH PATTERN (A+) DEFINE A AS price > 0); diff --git a/src/test/resources/net/sf/jsqlparser/statement/select/match-recognize/oracle-omit.sql b/src/test/resources/net/sf/jsqlparser/statement/select/match-recognize/oracle-omit.sql new file mode 100644 index 000000000..fda09ec68 --- /dev/null +++ b/src/test/resources/net/sf/jsqlparser/statement/select/match-recognize/oracle-omit.sql @@ -0,0 +1,11 @@ +/*- + * #%L + * JSQLParser library + * %% + * Copyright (C) 2004 - 2026 JSQLParser + * %% + * Dual licensed under GNU LGPL 2.1 or Apache License 2.0 + * #L% + */ +WITH events AS (SELECT 1 seq, 10 price FROM dual UNION ALL SELECT 2, 20 FROM dual UNION ALL SELECT 3, -5 FROM dual UNION ALL SELECT 4, 15 FROM dual) +SELECT * FROM events MATCH_RECOGNIZE (ORDER BY seq MEASURES COUNT(*) AS n ALL ROWS PER MATCH OMIT EMPTY MATCHES PATTERN (A*) DEFINE A AS price > 0) ; diff --git a/src/test/resources/net/sf/jsqlparser/statement/select/match-recognize/oracle-one.sql b/src/test/resources/net/sf/jsqlparser/statement/select/match-recognize/oracle-one.sql new file mode 100644 index 000000000..1bf773d9f --- /dev/null +++ b/src/test/resources/net/sf/jsqlparser/statement/select/match-recognize/oracle-one.sql @@ -0,0 +1,11 @@ +/*- + * #%L + * JSQLParser library + * %% + * Copyright (C) 2004 - 2026 JSQLParser + * %% + * Dual licensed under GNU LGPL 2.1 or Apache License 2.0 + * #L% + */ +WITH events AS (SELECT 1 seq, 10 price FROM dual UNION ALL SELECT 2, 20 FROM dual UNION ALL SELECT 3, -5 FROM dual UNION ALL SELECT 4, 15 FROM dual) +SELECT * FROM events MATCH_RECOGNIZE (ORDER BY seq MEASURES COUNT(*) AS n ONE ROW PER MATCH PATTERN (A+) DEFINE A AS price > 0) ; diff --git a/src/test/resources/net/sf/jsqlparser/statement/select/match-recognize/oracle-permute.sql b/src/test/resources/net/sf/jsqlparser/statement/select/match-recognize/oracle-permute.sql new file mode 100644 index 000000000..9263e2474 --- /dev/null +++ b/src/test/resources/net/sf/jsqlparser/statement/select/match-recognize/oracle-permute.sql @@ -0,0 +1,11 @@ +/*- + * #%L + * JSQLParser library + * %% + * Copyright (C) 2004 - 2026 JSQLParser + * %% + * Dual licensed under GNU LGPL 2.1 or Apache License 2.0 + * #L% + */ +WITH events AS (SELECT 1 seq, 10 price FROM dual UNION ALL SELECT 2, 20 FROM dual UNION ALL SELECT 3, -5 FROM dual UNION ALL SELECT 4, 15 FROM dual) +SELECT * FROM events MATCH_RECOGNIZE (ORDER BY seq MEASURES COUNT(*) AS n PATTERN (PERMUTE(A,B)) DEFINE A AS price > 0, B AS price <= 0) ; diff --git a/src/test/resources/net/sf/jsqlparser/statement/select/match-recognize/oracle-result-alias.sql b/src/test/resources/net/sf/jsqlparser/statement/select/match-recognize/oracle-result-alias.sql new file mode 100644 index 000000000..afd89a8fb --- /dev/null +++ b/src/test/resources/net/sf/jsqlparser/statement/select/match-recognize/oracle-result-alias.sql @@ -0,0 +1,11 @@ +/*- + * #%L + * JSQLParser library + * %% + * Copyright (C) 2004 - 2026 JSQLParser + * %% + * Dual licensed under GNU LGPL 2.1 or Apache License 2.0 + * #L% + */ +WITH events AS (SELECT 1 seq, 10 price FROM dual UNION ALL SELECT 2, 20 FROM dual UNION ALL SELECT 3, -5 FROM dual UNION ALL SELECT 4, 15 FROM dual) +SELECT * FROM events MATCH_RECOGNIZE (ORDER BY seq MEASURES COUNT(*) AS n PATTERN (A+) DEFINE A AS price > 0) m; diff --git a/src/test/resources/net/sf/jsqlparser/statement/select/match-recognize/oracle-running-final.sql b/src/test/resources/net/sf/jsqlparser/statement/select/match-recognize/oracle-running-final.sql new file mode 100644 index 000000000..f6b68f222 --- /dev/null +++ b/src/test/resources/net/sf/jsqlparser/statement/select/match-recognize/oracle-running-final.sql @@ -0,0 +1,11 @@ +/*- + * #%L + * JSQLParser library + * %% + * Copyright (C) 2004 - 2026 JSQLParser + * %% + * Dual licensed under GNU LGPL 2.1 or Apache License 2.0 + * #L% + */ +WITH events AS (SELECT 1 seq, 10 price FROM dual UNION ALL SELECT 2, 20 FROM dual UNION ALL SELECT 3, -5 FROM dual UNION ALL SELECT 4, 15 FROM dual) +SELECT * FROM events MATCH_RECOGNIZE (ORDER BY seq MEASURES RUNNING SUM(A.price) + FINAL SUM(A.price) AS total ALL ROWS PER MATCH PATTERN (A+) DEFINE A AS price > 0) ; diff --git a/src/test/resources/net/sf/jsqlparser/statement/select/match-recognize/oracle-show.sql b/src/test/resources/net/sf/jsqlparser/statement/select/match-recognize/oracle-show.sql new file mode 100644 index 000000000..c85d50fb5 --- /dev/null +++ b/src/test/resources/net/sf/jsqlparser/statement/select/match-recognize/oracle-show.sql @@ -0,0 +1,11 @@ +/*- + * #%L + * JSQLParser library + * %% + * Copyright (C) 2004 - 2026 JSQLParser + * %% + * Dual licensed under GNU LGPL 2.1 or Apache License 2.0 + * #L% + */ +WITH events AS (SELECT 1 seq, 10 price FROM dual UNION ALL SELECT 2, 20 FROM dual UNION ALL SELECT 3, -5 FROM dual UNION ALL SELECT 4, 15 FROM dual) +SELECT * FROM events MATCH_RECOGNIZE (ORDER BY seq MEASURES COUNT(*) AS n ALL ROWS PER MATCH SHOW EMPTY MATCHES PATTERN (A*) DEFINE A AS price > 0) ; diff --git a/src/test/resources/net/sf/jsqlparser/statement/select/match-recognize/oracle-skip-first.sql b/src/test/resources/net/sf/jsqlparser/statement/select/match-recognize/oracle-skip-first.sql new file mode 100644 index 000000000..f7aaf33a2 --- /dev/null +++ b/src/test/resources/net/sf/jsqlparser/statement/select/match-recognize/oracle-skip-first.sql @@ -0,0 +1,11 @@ +/*- + * #%L + * JSQLParser library + * %% + * Copyright (C) 2004 - 2026 JSQLParser + * %% + * Dual licensed under GNU LGPL 2.1 or Apache License 2.0 + * #L% + */ +WITH events AS (SELECT 1 seq, 10 price FROM dual UNION ALL SELECT 2, 20 FROM dual UNION ALL SELECT 3, -5 FROM dual UNION ALL SELECT 4, 15 FROM dual) +SELECT * FROM events MATCH_RECOGNIZE (ORDER BY seq MEASURES COUNT(*) AS n AFTER MATCH SKIP TO FIRST B PATTERN (A+ B) DEFINE A AS price > 0, B AS price <= 0) ; diff --git a/src/test/resources/net/sf/jsqlparser/statement/select/match-recognize/oracle-skip-last.sql b/src/test/resources/net/sf/jsqlparser/statement/select/match-recognize/oracle-skip-last.sql new file mode 100644 index 000000000..bfe76985c --- /dev/null +++ b/src/test/resources/net/sf/jsqlparser/statement/select/match-recognize/oracle-skip-last.sql @@ -0,0 +1,11 @@ +/*- + * #%L + * JSQLParser library + * %% + * Copyright (C) 2004 - 2026 JSQLParser + * %% + * Dual licensed under GNU LGPL 2.1 or Apache License 2.0 + * #L% + */ +WITH events AS (SELECT 1 seq, 10 price FROM dual UNION ALL SELECT 2, 20 FROM dual UNION ALL SELECT 3, -5 FROM dual UNION ALL SELECT 4, 15 FROM dual) +SELECT * FROM events MATCH_RECOGNIZE (ORDER BY seq MEASURES COUNT(*) AS n AFTER MATCH SKIP TO LAST B PATTERN (A+ B) DEFINE A AS price > 0, B AS price <= 0) ; diff --git a/src/test/resources/net/sf/jsqlparser/statement/select/match-recognize/oracle-skip-symbol.sql b/src/test/resources/net/sf/jsqlparser/statement/select/match-recognize/oracle-skip-symbol.sql new file mode 100644 index 000000000..19d03e606 --- /dev/null +++ b/src/test/resources/net/sf/jsqlparser/statement/select/match-recognize/oracle-skip-symbol.sql @@ -0,0 +1,11 @@ +/*- + * #%L + * JSQLParser library + * %% + * Copyright (C) 2004 - 2026 JSQLParser + * %% + * Dual licensed under GNU LGPL 2.1 or Apache License 2.0 + * #L% + */ +WITH events AS (SELECT 1 seq, 10 price FROM dual UNION ALL SELECT 2, 20 FROM dual UNION ALL SELECT 3, -5 FROM dual UNION ALL SELECT 4, 15 FROM dual) +SELECT * FROM events MATCH_RECOGNIZE (ORDER BY seq MEASURES COUNT(*) AS n AFTER MATCH SKIP TO B PATTERN (A+ B) DEFINE A AS price > 0, B AS price <= 0) ; diff --git a/src/test/resources/net/sf/jsqlparser/statement/select/match-recognize/oracle-subset.sql b/src/test/resources/net/sf/jsqlparser/statement/select/match-recognize/oracle-subset.sql new file mode 100644 index 000000000..3ead0335d --- /dev/null +++ b/src/test/resources/net/sf/jsqlparser/statement/select/match-recognize/oracle-subset.sql @@ -0,0 +1,11 @@ +/*- + * #%L + * JSQLParser library + * %% + * Copyright (C) 2004 - 2026 JSQLParser + * %% + * Dual licensed under GNU LGPL 2.1 or Apache License 2.0 + * #L% + */ +WITH events AS (SELECT 1 seq, 10 price FROM dual UNION ALL SELECT 2, 20 FROM dual UNION ALL SELECT 3, -5 FROM dual UNION ALL SELECT 4, 15 FROM dual) +SELECT * FROM events MATCH_RECOGNIZE (ORDER BY seq MEASURES SUM(U.price) AS total PATTERN (A+ B) SUBSET U = (A,B) DEFINE A AS price > 0, B AS price <= 0) ; diff --git a/src/test/resources/net/sf/jsqlparser/statement/select/match-recognize/oracle-unmatched.sql b/src/test/resources/net/sf/jsqlparser/statement/select/match-recognize/oracle-unmatched.sql new file mode 100644 index 000000000..c75ce5759 --- /dev/null +++ b/src/test/resources/net/sf/jsqlparser/statement/select/match-recognize/oracle-unmatched.sql @@ -0,0 +1,11 @@ +/*- + * #%L + * JSQLParser library + * %% + * Copyright (C) 2004 - 2026 JSQLParser + * %% + * Dual licensed under GNU LGPL 2.1 or Apache License 2.0 + * #L% + */ +WITH events AS (SELECT 1 seq, 10 price FROM dual UNION ALL SELECT 2, 20 FROM dual UNION ALL SELECT 3, -5 FROM dual UNION ALL SELECT 4, 15 FROM dual) +SELECT * FROM events MATCH_RECOGNIZE (ORDER BY seq MEASURES COUNT(*) AS n ALL ROWS PER MATCH WITH UNMATCHED ROWS PATTERN (A+) DEFINE A AS price > 0) ;