Skip to content

Commit

Permalink
feat(content analytics) fixes #30521 : Allow users to pass down simpl…
Browse files Browse the repository at this point in the history
…e Strings to query for Content Analytics data
  • Loading branch information
jcastro-dotcms committed Dec 6, 2024
1 parent da51ee9 commit ddb980a
Show file tree
Hide file tree
Showing 3 changed files with 207 additions and 23 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,28 @@
/**
* This class represents the parameters of a Content Analytics Query abstracting the complexity
* of the underlying JSON format. The simplified REST Endpoint and the Content Analytics ViewTool
* use this class so that parameters can be entered in a more user-friendly way.
* use this class so that parameters can be entered in a more user-friendly way. Here's an example
* of what this simple JSON data looks like:
* <pre>
* {@code
* {
* "measures": "request.count,request.totalSessions",
* "dimensions": "request.host,request.whatAmI,request.url",
* "timeDimensions": "request.createdAt,day:Last month",
* "filters": "request.totalRequest gt 0,request.whatAmI contains PAGE||FILE",
* "order": "request.count asc,request.createdAt asc",
* "limit": 5,
* "offset": 0
* }
* }
* </pre>
* Notice how there are four separator characters:
* <ul>
* <li>Blank space.</li>
* <li>Comma.</li>
* <li>Colon.</li>
* <li>Double pipes.</li>
* </ul>
*
* @author Jose Castro
* @since Nov 28th, 2024
Expand Down Expand Up @@ -54,7 +75,11 @@ public class ContentAnalyticsQuery implements Serializable {
@JsonProperty()
private final int offset;

private static final String SEPARATOR = COLON;
private static final String SEPARATOR_1 = "\\s+";
private static final String SEPARATOR_2 = COLON;
private static final String SEPARATOR_3 = COMMA;
private static final String SEPARATOR_4 = "\\|\\|";
private static final String DEFAULT_DATE_RANGE = "Last week";

private ContentAnalyticsQuery(final Builder builder) {
this.measures = builder.measures;
Expand Down Expand Up @@ -129,12 +154,12 @@ public static class Builder {
* The measures parameter contains a set of measures and each measure is an aggregation over
* a certain column in your ClickHouse database table.
*
* @param measures A string with the measures separated by a space.
* @param measures A string with the measures separated by {@link #SEPARATOR_3}.
*
* @return The builder instance.
*/
public Builder measures(final String measures) {
this.measures = Set.of(measures.split("\\s+"));
this.measures = Set.of(measures.split(SEPARATOR_3));
return this;
}

Expand All @@ -143,12 +168,12 @@ public Builder measures(final String measures) {
* an attribute related to a measure, e.g. the measure user_count can have dimensions like
* country, age, occupation, etc.
*
* @param dimensions A string with the dimensions separated by a space.
* @param dimensions A string with the dimensions separated by {@link #SEPARATOR_3}.
*
* @return The builder instance.
*/
public Builder dimensions(final String dimensions) {
this.dimensions = Set.of(dimensions.split("\\s+"));
this.dimensions = Set.of(dimensions.split(SEPARATOR_3));
return this;
}

Expand All @@ -157,24 +182,28 @@ public Builder dimensions(final String dimensions) {
* an array of objects in timeDimension format. If no date range is provided, the default
* value will be "Last week".
*
* @param timeDimensions A string with the time dimensions separated by a colon.
* @param timeDimensions A string with the time dimensions separated by
* {@link #SEPARATOR_3}.
*
* @return The builder instance.
*/
public Builder timeDimensions(final String timeDimensions) {
if (UtilMethods.isNotSet(timeDimensions)) {
return this;
}
final String[] timeParams = timeDimensions.split(SEPARATOR);
final String[] timeParams = timeDimensions.split(SEPARATOR_3);
final Map<String, String> timeDimensionsData = new HashMap<>();
timeDimensionsData.put(TIME_DIMENSIONS_DIMENSION_ATTR, timeParams[0]);
if (timeParams.length > 2) {
timeDimensionsData.put(GRANULARITY_ATTR, timeParams[1]);
timeDimensionsData.put(DATE_RANGE_ATTR, timeParams[2]);
} else if (timeParams.length > 1) {
timeDimensionsData.put(DATE_RANGE_ATTR, timeParams[1]);
if (timeParams.length > 1) {
final String[] granularityAndRange = timeParams[1].split(SEPARATOR_2);
if (granularityAndRange.length > 1) {
timeDimensionsData.put(GRANULARITY_ATTR, granularityAndRange[0]);
timeDimensionsData.put(DATE_RANGE_ATTR, granularityAndRange[1]);
} else {
timeDimensionsData.put(DATE_RANGE_ATTR, granularityAndRange[0]);
}
} else {
timeDimensionsData.put(DATE_RANGE_ATTR, "Last week");
timeDimensionsData.put(DATE_RANGE_ATTR, DEFAULT_DATE_RANGE);
}
this.timeDimensions.add(timeDimensionsData);
return this;
Expand All @@ -184,23 +213,23 @@ public Builder timeDimensions(final String timeDimensions) {
* Filters are applied differently to dimensions and measures. When you filter on a
* dimension, you are restricting the raw data before any calculations are made. When you
* filter on a measure, you are restricting the results after the measure has been
* calculated. They are composed of: member, operator, and values.
* calculated. They are composed of 3 parts: member, operator, and values.
*
* @param filters A string with the filters separated by a colon.
* @param filters A string with the filters separated by {@link #SEPARATOR_3}.
*
* @return The builder instance.
*/
public Builder filters(final String filters) {
if (UtilMethods.isNotSet(filters)) {
return this;
}
final String[] filterArr = filters.split(SEPARATOR);
final String[] filterArr = filters.split(SEPARATOR_3);
for (final String filter : filterArr) {
final String[] filterParams = filter.split("\\s+");
final String[] filterParams = filter.split(SEPARATOR_1);
final Map<String, Object> filterDataMap = new HashMap<>();
filterDataMap.put(MEMBER_ATTR, filterParams[0]);
filterDataMap.put(OPERATOR_ATTR, filterParams[1]);
final String[] filterValues = filterParams[2].split(COMMA);
final String[] filterValues = filterParams[2].split(SEPARATOR_4);
filterDataMap.put(VALUES_ATTR, filterValues);
this.filters.add(filterDataMap);
}
Expand All @@ -213,17 +242,17 @@ public Builder filters(final String filters) {
* on the order of the keys in the object. If not provided, default ordering is applied. If
* an empty object ([]) is provided, no ordering is applied.
*
* @param order A string with the order separated by a colon.
* @param order A string with the order separated by {@link #SEPARATOR_3}.
*
* @return The builder instance.
*/
public Builder order(final String order) {
if (UtilMethods.isNotSet(order)) {
return this;
}
final Set<String> orderCriteria = Set.of(order.split(SEPARATOR));
final Set<String> orderCriteria = Set.of(order.split(SEPARATOR_3));
for (final String orderCriterion : orderCriteria) {
final String[] orderParams = orderCriterion.split("\\s+");
final String[] orderParams = orderCriterion.split(SEPARATOR_1);
this.order.add(orderParams);
}
return this;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
package com.dotcms.analytics.content;

import com.dotcms.util.JsonUtil;
import org.junit.Test;

import java.io.IOException;
import java.util.Arrays;
import java.util.List;
import java.util.Map;

import static com.dotcms.analytics.content.ContentAnalyticsQuery.DATE_RANGE_ATTR;
import static com.dotcms.analytics.content.ContentAnalyticsQuery.DIMENSIONS_ATTR;
import static com.dotcms.analytics.content.ContentAnalyticsQuery.FILTERS_ATTR;
import static com.dotcms.analytics.content.ContentAnalyticsQuery.GRANULARITY_ATTR;
import static com.dotcms.analytics.content.ContentAnalyticsQuery.MEASURES_ATTR;
import static com.dotcms.analytics.content.ContentAnalyticsQuery.MEMBER_ATTR;
import static com.dotcms.analytics.content.ContentAnalyticsQuery.OPERATOR_ATTR;
import static com.dotcms.analytics.content.ContentAnalyticsQuery.ORDER_ATTR;
import static com.dotcms.analytics.content.ContentAnalyticsQuery.TIME_DIMENSIONS_ATTR;
import static com.dotcms.analytics.content.ContentAnalyticsQuery.TIME_DIMENSIONS_DIMENSION_ATTR;
import static com.dotcms.analytics.content.ContentAnalyticsQuery.VALUES_ATTR;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;

/**
* Verifies that the {@link ContentAnalyticsQuery} class is working as expected.
*
* @author Jose Castro
* @since Dec 5th, 2024
*/
public class ContentAnalyticsQueryTest {

/**
* <ul>
* <li><b>Method to test: </b>{@link ContentAnalyticsQuery.Builder#build(Map)}</li>
* </li>
* <li><b>Given Scenario: </b>Transform the parameters of a CubeJS query as simple Strings
* into an actual CubeJS query.</li>
* <li><b>Expected Result: </b>The generated CubeJS JSON query must contain the exact same
* parameters as the original ones, even if their formatting is different.</li>
* </ul>
*/
@SuppressWarnings("unchecked")
@Test
public void getCubeJSQueryFromSimpleString() throws IOException {
// ╔════════════════════════╗
// ║ Generating Test Data ║
// ╚════════════════════════╝
final String testQueryParams = "{\n" +
" \"measures\": \"request.count,request.totalSessions\",\n" +
" \"dimensions\": \"request.host,request.whatAmI,request.url\",\n" +
" \"timeDimensions\": \"request.createdAt,day:Last month\",\n" +
" \"filters\": \"request.totalRequest gt 0,request.whatAmI contains PAGE||FILE\",\n" +
" \"order\": \"request.count asc,request.createdAt asc\",\n" +
" \"limit\": 5,\n" +
" \"offset\": 0\n" +
"}";
final Map<String, Object> queryParamsMap = JsonUtil.getJsonFromString(testQueryParams);

// ╔══════════════════╗
// ║ Initialization ║
// ╚══════════════════╝
final ContentAnalyticsQuery contentAnalyticsQuery =
new ContentAnalyticsQuery.Builder().build(queryParamsMap);
final String cubeJsQuery = JsonUtil.getJsonStringFromObject(contentAnalyticsQuery);
final Map<String, Object> mapFromGeneratedQuery = JsonUtil.getJsonFromString(cubeJsQuery);

// ╔══════════════╗
// ║ Assertions ║
// ╚══════════════╝
// Checking measures
boolean expectedMeasures = false;
for (final String measure : contentAnalyticsQuery.measures()) {
expectedMeasures = ((List<String>) mapFromGeneratedQuery.get(MEASURES_ATTR)).contains(measure);
if (!expectedMeasures) {
break;
}
}
assertTrue(expectedMeasures, "Generated measures don't match the expected ones");

// Checking dimensions
boolean expectedDimensions = false;
for (final String dimension : contentAnalyticsQuery.dimensions()) {
expectedDimensions = ((List<String>) mapFromGeneratedQuery.get(DIMENSIONS_ATTR)).contains(dimension);
if (!expectedDimensions) {
break;
}
}
assertTrue(expectedDimensions, "Generated dimensions don't match the expected ones");

// Checking time dimensions
boolean expectedTimeDimensions = false;
for (final Map<String, String> timeDimension : contentAnalyticsQuery.timeDimensions()) {
final Map<String, String> generatedTimeDimension =
((List<Map<String, String>>) mapFromGeneratedQuery.get(TIME_DIMENSIONS_ATTR)).get(0);
expectedTimeDimensions =
generatedTimeDimension.containsKey(TIME_DIMENSIONS_DIMENSION_ATTR)
&& generatedTimeDimension.containsKey(GRANULARITY_ATTR)
&& generatedTimeDimension.containsKey(DATE_RANGE_ATTR)
&& generatedTimeDimension.get(TIME_DIMENSIONS_DIMENSION_ATTR).equals(timeDimension.get(TIME_DIMENSIONS_DIMENSION_ATTR))
&& generatedTimeDimension.get(GRANULARITY_ATTR).equals(timeDimension.get(GRANULARITY_ATTR))
&& generatedTimeDimension.get(DATE_RANGE_ATTR).equals(timeDimension.get(DATE_RANGE_ATTR));
if (!expectedTimeDimensions) {
break;
}
}
assertTrue(expectedTimeDimensions, "Generated time dimensions don't match the expected ones");

// Checking filters and values for each filter
boolean expectedFilters = false;
for (final Map<String, Object> filter : contentAnalyticsQuery.filters()) {
for (final Map<String, Object> generatedFilter : (List<Map<String, Object>>) mapFromGeneratedQuery.get(FILTERS_ATTR)) {
if (generatedFilter.get(MEMBER_ATTR).equals(filter.get(MEMBER_ATTR))) {
final String generatedValues = generatedFilter.get(VALUES_ATTR).toString();
final String expectedValues =
Arrays.asList((String[]) filter.get(VALUES_ATTR)).toString();
expectedFilters = generatedFilter.containsKey(MEMBER_ATTR)
&& generatedFilter.containsKey(OPERATOR_ATTR)
&& generatedFilter.containsKey(VALUES_ATTR)
&& generatedFilter.get(MEMBER_ATTR).equals(filter.get(MEMBER_ATTR))
&& generatedFilter.get(OPERATOR_ATTR).equals(filter.get(OPERATOR_ATTR))
&& generatedValues.equals(expectedValues);
if (expectedFilters) {
continue;
}
break;
}
}
}
assertTrue(expectedFilters, "Generated filters don't match the expected ones");

// Checking order
boolean expectedOrder = false;
for (final String[] order : contentAnalyticsQuery.order()) {
final String key = order[0];
for (final List<String> generatedOrder : (List<List<String>>)mapFromGeneratedQuery.get(ORDER_ATTR)) {
expectedOrder = generatedOrder.get(0).equals(key) && generatedOrder.get(1).equals(order[1]);
if (expectedOrder) {
break;
}
}
if (!expectedOrder) {
break;
}
}
assertTrue(expectedOrder, "Generated order values don't match the expected ones");

// Checking limit
assertEquals(contentAnalyticsQuery.limit(), Integer.parseInt(mapFromGeneratedQuery.get("limit").toString()), "Generated limit value doesn't match the expected one");

// Checking offset
assertEquals(contentAnalyticsQuery.offset(), Integer.parseInt(mapFromGeneratedQuery.get("offset").toString()), "Generated offset value doesn't match the expected one");
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@
"header": [],
"body": {
"mode": "raw",
"raw": "{\n \"measures\": \"request.count request.totalSessions\",\n \"dimensions\": \"request.host request.whatAmI request.url\",\n \"timeDimensions\": \"request.createdAt:day:Last month\",\n \"filters\": \"request.totalRequest gt 0:request.whatAmI contains PAGE,FILE\",\n \"order\": \"request.count asc:request.createdAt asc\",\n \"limit\": 5,\n \"offset\": 0\n}",
"raw": "{\n \"measures\": \"request.count,request.totalSessions\",\n \"dimensions\": \"request.host,request.whatAmI,request.url\",\n \"timeDimensions\": \"request.createdAt,day:Last month\",\n \"filters\": \"request.totalRequest gt 0,request.whatAmI contains PAGE||FILE\",\n \"order\": \"request.count asc,request.createdAt asc\",\n \"limit\": 5,\n \"offset\": 0\n}",
"options": {
"raw": {
"language": "json"
Expand Down

0 comments on commit ddb980a

Please sign in to comment.