Skip to content

Commit

Permalink
feat(Content Analytics) fixes #30191 : Support multiple values for th…
Browse files Browse the repository at this point in the history
…e `filters` attribute in our Content Analytics Query (#30210)

### Proposed Changes
* Supporting the comparison of an array of values for the `filters`
attribute in our Content Analytics Query. This way, our custom filter
parameter can work just like it does in a CubeJS Query.
  • Loading branch information
jcastro-dotcms authored Oct 2, 2024
1 parent 98f280e commit a93b537
Show file tree
Hide file tree
Showing 5 changed files with 181 additions and 68 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -26,16 +26,15 @@ public ContentAnalyticsAPIImpl(final ContentAnalyticsFactory contentAnalyticsFac

@Override
public ReportResponse runReport(final AnalyticsQuery query, final User user) {

Logger.debug(this, ()-> "Running the report for the query: " + query);
// note: should check any permissions for an user.
// TODO: We should check for specific user permissions
return this.contentAnalyticsFactory.getReport(query, user);
}

@Override
public ReportResponse runRawReport(CubeJSQuery cubeJSQuery, User user) {
public ReportResponse runRawReport(final CubeJSQuery cubeJSQuery, final User user) {
Logger.debug(this, ()-> "Running the report for the raw query: " + cubeJSQuery);
// note: should check any permissions for an user.
// TODO: We should check for specific user permissions
return this.contentAnalyticsFactory.getRawReport(cubeJSQuery, user);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,23 @@

import javax.enterprise.context.ApplicationScoped;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.List;
import java.util.Objects;
import java.util.stream.Collectors;
import java.util.stream.Stream;

import static com.liferay.util.StringPool.APOSTROPHE;
import static com.liferay.util.StringPool.BLANK;

/**
* Parser for the analytics query, it can parse a json string to a {@link AnalyticsQuery} or a {@link CubeJSQuery}
* This class exposes a parser for the {@link AnalyticsQuery} class. It can parse a JSON string
* into a {@link AnalyticsQuery} object or a {@link CubeJSQuery} object. The Analytics Query
* exposes a more readable and simple syntax compared to the CubeJS query
*
* @author jsanca
* @since Sep 19th, 2024
*/
@ApplicationScoped
public class AnalyticsQueryParser {
Expand All @@ -44,14 +52,14 @@ public class AnalyticsQueryParser {
public AnalyticsQuery parseJsonToQuery(final String json) {

if (Objects.isNull(json)) {
throw new IllegalArgumentException("Json can not be null");
throw new IllegalArgumentException("JSON cannot be null");
}
try {

Logger.debug(this, ()-> "Parsing json to query: " + json);
Logger.debug(this, ()-> "Parsing json query: " + json);
return DotObjectMapperProvider.getInstance().getDefaultObjectMapper()
.readValue(json, AnalyticsQuery.class);
} catch (JsonProcessingException e) {
} catch (final JsonProcessingException e) {
Logger.error(this, e.getMessage(), e);
throw new DotRuntimeException(e);
}
Expand Down Expand Up @@ -80,18 +88,20 @@ public CubeJSQuery parseJsonToCubeQuery(final String json) {
}

/**
* Parse an {@link AnalyticsQuery} to a {@link CubeJSQuery}
* @param query
* @return CubeJSQuery
* Parses an {@link AnalyticsQuery} object into a {@link CubeJSQuery} object, which represents
* the official CubeJS query.
*
* @param query The {@link AnalyticsQuery} object to be parsed.
*
* @return The {@link CubeJSQuery} object.
*/
public CubeJSQuery parseQueryToCubeQuery(final AnalyticsQuery query) {

if (Objects.isNull(query)) {
throw new IllegalArgumentException("Query can not be null");
throw new IllegalArgumentException("Query cannot be null");
}

final CubeJSQuery.Builder builder = new CubeJSQuery.Builder();
Logger.debug(this, ()-> "Parsing query to cube query: " + query);
Logger.debug(this, ()-> "Parsing query: " + query);

if (UtilMethods.isSet(query.getDimensions())) {
builder.dimensions(query.getDimensions());
Expand Down Expand Up @@ -136,6 +146,15 @@ private Collection<CubeJSQuery.OrderItem> parseOrders(final String orders) {
).collect(Collectors.toList());
}

/**
* Parses the value of the {@code filters} attribute of the {@link AnalyticsQuery} object. This
* filter can have several logical operators, and be able to compare a variable against multiple
* values.
*
* @param filters the value of the {@code filters} attribute.
*
* @return A collection of {@link Filter} objects.
*/
private Collection<Filter> parseFilters(final String filters) {
final Tuple2<List<FilterParser.Token>,List<FilterParser.LogicalOperator>> result =
FilterParser.parseFilterExpression(filters);
Expand All @@ -144,14 +163,13 @@ private Collection<Filter> parseFilters(final String filters) {
final List<SimpleFilter> simpleFilters = new ArrayList<>();

for (final FilterParser.Token token : result._1) {

simpleFilters.add(
new SimpleFilter(token.member,
parseOperator(token.operator),
new Object[]{token.values}));
parseOperator(token.operator),
this.parseTokenValues(token.values)));
}

// if has operators
// Are there any operators?
if (UtilMethods.isSet(result._2())) {

FilterParser.LogicalOperator logicalOperator = result._2().get(0); // first one
Expand All @@ -177,6 +195,21 @@ private Collection<Filter> parseFilters(final String filters) {
return filterList;
}

/**
* Takes the value of a token and parses its contents into an array of strings. A token can have
* both single and multiple values.
*
* @param values The value of the token.
*
* @return The value or values of a token as an array of strings.
*/
private String[] parseTokenValues(final String values) {
final String[] valueArray = values.split(",");
return Arrays.stream(valueArray).map(
value -> value.trim().replaceAll(APOSTROPHE, BLANK))
.toArray(String[]::new);
}

private SimpleFilter.Operator parseOperator(final String operator) {
switch (operator) {
case "=":
Expand All @@ -191,4 +224,5 @@ private SimpleFilter.Operator parseOperator(final String operator) {
throw new DotRuntimeException("Operator not supported: " + operator);
}
}

}
56 changes: 36 additions & 20 deletions dotCMS/src/main/java/com/dotcms/analytics/query/FilterParser.java
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package com.dotcms.analytics.query;

import com.dotmarketing.util.Logger;
import com.dotmarketing.util.UtilMethods;
import io.vavr.Tuple;
import io.vavr.Tuple2;

Expand All @@ -9,23 +11,27 @@
import java.util.regex.Pattern;

/**
* Parser for a filter expression
* Example:
* This class exposes a parser for the {@code filters} attribute in our Analytics Query object. For
* example, the following expression:
* <pre>
* FilterParser.parseFilterExpression("Events.variant = ['B'] or Events.experiments = ['C']");
* {@code FilterParser.parseFilterExpression("Events.variant = ['B'] or Events.experiments = ['C']");}
* </pre>
* should return 2 tokens and 1 logical operator
* Tokens are member, operator and values (Events.variant, =, B) and the operator is 'and' or 'or'
* <p>Should return 2 tokens and 1 logical operator. Tokens are composed of a member, an operator,
* and one or more values.</p>
*
* @author jsanca
* @since Sep 19th, 2024
*/
public class FilterParser {

private static final String EXPRESSION_REGEX = "(\\w+\\.\\w+)\\s*(=|!=|in|!in)\\s*\\['(.*?)'";
private static final String EXPRESSION_REGEX = "(\\w+\\.\\w+)\\s*(=|!=|in|!in)\\s*\\[\\s*((?:'([^']*)'|\\d+)(?:\\s*,\\s*(?:'([^']*)'|\\d+))*)\\s*]";
private static final String LOGICAL_OPERATOR_REGEX = "\\s*(and|or)\\s*";

private static final Pattern TOKEN_PATTERN = Pattern.compile(EXPRESSION_REGEX);
private static final Pattern LOGICAL_PATTERN = Pattern.compile(LOGICAL_OPERATOR_REGEX);

static class Token {
public static class Token {

String member;
String operator;
String values;
Expand All @@ -37,37 +43,46 @@ public Token(final String member,
this.operator = operator;
this.values = values;
}

}

enum LogicalOperator {
public enum LogicalOperator {
AND,
OR,
UNKNOWN
}

/**
* This method parser the filter expression such as
* [Events.variant = [“B”] or Events.experiments = [“B”]]
* @param expression String
* @return return the token expression plus the logical operators
* Parses the value of the {@code filter} attribute, allowing both single and multiple values.
* For instance, the following expression:
* <pre>
* {@code request.whatAmI = ['PAGE','FILE'] and request.url in ['/blog']}
* </pre>
* <p>Allows you to retrieve results for both HTML Pages and Files whose URL contains the
* String {@code "/blog"}.</p>
*
* @param expression The value of the {@code filter} attribute.
*
* @return A {@link Tuple2} object containing token expression plus the logical operators.
*/
public static Tuple2<List<Token>,List<LogicalOperator>> parseFilterExpression(final String expression) {

final List<Token> tokens = new ArrayList<>();
final List<LogicalOperator> logicalOperators = new ArrayList<>();
// note:Need to use cache here
if (UtilMethods.isNotSet(expression)) {
return Tuple.of(tokens, logicalOperators);
}
// TODO: We need to use cache here
final Matcher tokenMatcher = TOKEN_PATTERN.matcher(expression);

// Extract the tokens (member, operator, values)
while (tokenMatcher.find()) {
final String member = tokenMatcher.group(1); // Example: Events.variant
final String operator = tokenMatcher.group(2); // Example: =
final String values = tokenMatcher.group(3); // Example: "B"
final String values = tokenMatcher.group(3); // Example: "'B'", or multiple values such as "'A', 'B'"
tokens.add(new Token(member, operator, values));
}

// Pattern for logical operators (and, or)
// Need to use cache here
// TODO: Need to use cache here
final Matcher logicalMatcher = LOGICAL_PATTERN.matcher(expression);

// Extract logical operators
Expand All @@ -76,9 +91,9 @@ public static Tuple2<List<Token>,List<LogicalOperator>> parseFilterExpression(fi
logicalOperators.add(parseLogicalOperator(logicalOperator));
}

// if any unknown should fails
// note: should validate logical operators should be length - 1 of the tokens???

if (tokens.isEmpty() && logicalOperators.isEmpty()) {
Logger.warn(FilterParser.class, String.format("Filter expression failed to be parsed: %s", expression));
}
return Tuple.of(tokens, logicalOperators);
}

Expand All @@ -93,4 +108,5 @@ private static LogicalOperator parseLogicalOperator(final String logicalOperator
return LogicalOperator.UNKNOWN;
}
}

}
6 changes: 5 additions & 1 deletion dotCMS/src/main/java/com/dotcms/cube/filters/Filter.java
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@
* Represents a CubeJs Query Filter
*
* @see <a href="https://cube.dev/docs/query-format#filters-formate">Filters format</a>
*
* @author Freddy Rodriguez
* @since Jan 27th, 2023
*/
public interface Filter {

Expand All @@ -15,7 +18,8 @@ static LogicalFilter.Builder and(){

Map<String, Object> asMap();

public enum Order {
enum Order {
ASC, DESC;
}

}
Loading

0 comments on commit a93b537

Please sign in to comment.