Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(content analytics) fixes #30521 : Allow users to pass down simple Strings to query for Content Analytics data #30869

Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 = "\\|\\|";
jdotcms marked this conversation as resolved.
Show resolved Hide resolved
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
Loading