diff --git a/dotCMS/src/main/java/com/dotcms/analytics/content/ContentAnalyticsQuery.java b/dotCMS/src/main/java/com/dotcms/analytics/content/ContentAnalyticsQuery.java index 9466e308678f..4f51be9163c1 100644 --- a/dotCMS/src/main/java/com/dotcms/analytics/content/ContentAnalyticsQuery.java +++ b/dotCMS/src/main/java/com/dotcms/analytics/content/ContentAnalyticsQuery.java @@ -10,14 +10,49 @@ import java.util.List; import java.util.Map; import java.util.Set; +import java.util.stream.Collectors; import static com.liferay.util.StringPool.COLON; import static com.liferay.util.StringPool.COMMA; +import static com.liferay.util.StringPool.PERIOD; /** * 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 for the default {@code request} schema: + *
+ *     {@code
+ *     {
+ *       "measures": "count,totalSessions",
+ *       "dimensions": "host,whatAmI,url",
+ *       "timeDimensions": "createdAt,day:Last month",
+ *       "filters": "totalRequest gt 0,whatAmI contains PAGE||FILE",
+ *       "order": "count asc,createdAt asc",
+ *       "limit": 5,
+ *       "offset": 0
+ *     }
+ *     }
+ * 
+ * Under the covers, this builder will prefix the appropriate terms with the specified or default + * schema. If you want to provide a specific one, just add it to the JSON body: + *
+ *     {@code
+ *     {
+ *         "scheme": "YOUR-SCHEME-NAME-HERE",
+ *         ...
+ *         ...
+ *     }
+ *     }
+ * 
+ * Notice how there are four separator characters for different parameters. They must be used + * correctly for the data to be parsed correctly: + * * * @author Jose Castro * @since Nov 28th, 2024 @@ -25,6 +60,7 @@ @JsonDeserialize(builder = ContentAnalyticsQuery.Builder.class) public class ContentAnalyticsQuery implements Serializable { + public static final String SCHEME_ATTR = "scheme"; public static final String MEASURES_ATTR = "measures"; public static final String DIMENSIONS_ATTR = "dimensions"; public static final String TIME_DIMENSIONS_ATTR = "timeDimensions"; @@ -39,6 +75,7 @@ public class ContentAnalyticsQuery implements Serializable { public static final String OPERATOR_ATTR = "operator"; public static final String VALUES_ATTR = "values"; + private final String scheme; @JsonProperty() private final Set measures; @JsonProperty() @@ -54,9 +91,13 @@ public class ContentAnalyticsQuery implements Serializable { @JsonProperty() private final int offset; - private static final String SEPARATOR = COLON; + private static final String SPACE = "\\s+"; + private static final String DOUBLE_PIPE = "\\|\\|"; + private static final String DEFAULT_DATE_RANGE = "Last week"; + private static final String DEFAULT_SCHEME = "request"; private ContentAnalyticsQuery(final Builder builder) { + this.scheme = builder.scheme; this.measures = builder.measures; this.dimensions = builder.dimensions; this.timeDimensions = builder.timeDimensions; @@ -66,6 +107,10 @@ private ContentAnalyticsQuery(final Builder builder) { this.offset = builder.offset; } + public String scheme() { + return this.scheme; + } + public Set measures() { return this.measures; } @@ -101,13 +146,14 @@ public static ContentAnalyticsQuery.Builder builder() { @Override public String toString() { return "ContentAnalyticsQuery{" + - "measures='" + measures + '\'' + - ", dimensions='" + dimensions + '\'' + - ", timeDimensions='" + timeDimensions + '\'' + - ", filters='" + filters + '\'' + - ", order='" + order + '\'' + - ", limit='" + limit + '\'' + - ", offset='" + offset + '\'' + + "scheme='" + scheme + '\'' + + ", measures=" + measures + + ", dimensions=" + dimensions + + ", timeDimensions=" + timeDimensions + + ", filters=" + filters + + ", order=" + order + + ", limit=" + limit + + ", offset=" + offset + '}'; } @@ -117,6 +163,7 @@ public String toString() { */ public static class Builder { + private String scheme = DEFAULT_SCHEME; private Set measures; private Set dimensions; private final List> timeDimensions = new ArrayList<>(); @@ -125,16 +172,29 @@ public static class Builder { private int limit = 1000; private int offset = 0; + /** + * Sets the default scheme for the parameters sent to the Content Analytics service. + * + * @param scheme The default scheme for the parameters. + * + * @return The builder instance. + */ + public Builder scheme(final String scheme) { + this.scheme = scheme; + return this; + } + /** * 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 com.liferay.util.StringPool#COMMA}. * * @return The builder instance. */ public Builder measures(final String measures) { - this.measures = Set.of(measures.split("\\s+")); + this.measures = addScheme(Set.of(measures.split(COMMA))); return this; } @@ -143,12 +203,13 @@ 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 com.liferay.util.StringPool#COMMA}. * * @return The builder instance. */ public Builder dimensions(final String dimensions) { - this.dimensions = Set.of(dimensions.split("\\s+")); + this.dimensions = addScheme(Set.of(dimensions.split(COMMA))); return this; } @@ -157,7 +218,8 @@ 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 com.liferay.util.StringPool#COMMA}. * * @return The builder instance. */ @@ -165,16 +227,19 @@ public Builder timeDimensions(final String timeDimensions) { if (UtilMethods.isNotSet(timeDimensions)) { return this; } - final String[] timeParams = timeDimensions.split(SEPARATOR); + final String[] timeParams = timeDimensions.split(COMMA); final Map 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]); + timeDimensionsData.put(TIME_DIMENSIONS_DIMENSION_ATTR, addScheme(timeParams[0])); + if (timeParams.length > 1) { + final String[] granularityAndRange = timeParams[1].split(COLON); + 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; @@ -184,9 +249,10 @@ 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 com.liferay.util.StringPool#COMMA}. * * @return The builder instance. */ @@ -194,13 +260,13 @@ public Builder filters(final String filters) { if (UtilMethods.isNotSet(filters)) { return this; } - final String[] filterArr = filters.split(SEPARATOR); + final String[] filterArr = filters.split(COMMA); for (final String filter : filterArr) { - final String[] filterParams = filter.split("\\s+"); + final String[] filterParams = filter.split(SPACE); final Map filterDataMap = new HashMap<>(); - filterDataMap.put(MEMBER_ATTR, filterParams[0]); + filterDataMap.put(MEMBER_ATTR, addScheme(filterParams[0])); filterDataMap.put(OPERATOR_ATTR, filterParams[1]); - final String[] filterValues = filterParams[2].split(COMMA); + final String[] filterValues = filterParams[2].split(DOUBLE_PIPE); filterDataMap.put(VALUES_ATTR, filterValues); this.filters.add(filterDataMap); } @@ -213,7 +279,8 @@ 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 com.liferay.util.StringPool#COMMA}. * * @return The builder instance. */ @@ -221,10 +288,14 @@ public Builder order(final String order) { if (UtilMethods.isNotSet(order)) { return this; } - final Set orderCriteria = Set.of(order.split(SEPARATOR)); + final Set orderCriteria = Set.of(order.split(COMMA)); for (final String orderCriterion : orderCriteria) { - final String[] orderParams = orderCriterion.split("\\s+"); - this.order.add(orderParams); + final String[] orderParams = orderCriterion.split(SPACE); + if (orderParams.length > 1) { + this.order.add(new String[]{addScheme(orderParams[0]), orderParams[1]}); + } else { + this.order.add(orderParams); + } } return this; } @@ -253,7 +324,6 @@ public Builder offset(final int offset) { return this; } - /** * This method builds the ContentAnalyticsQuery object based on all the specified * parameters for the query. @@ -268,21 +338,46 @@ public ContentAnalyticsQuery build() { * This method builds the ContentAnalyticsQuery object based on all the specified * parameters in the provided map. * - * @param form A {@link Map} containing the query data. + * @param parameters A {@link Map} containing the query data. * * @return The {@link ContentAnalyticsQuery} object. */ - public ContentAnalyticsQuery build(final Map form) { - this.measures((String) form.get(MEASURES_ATTR)); - this.dimensions((String) form.get(DIMENSIONS_ATTR)); - this.timeDimensions((String) form.get(TIME_DIMENSIONS_ATTR)); - this.filters((String) form.get(FILTERS_ATTR)); - this.order((String) form.get(ORDER_ATTR)); - this.limit((Integer) form.get(LIMIT_ATTR)); - this.offset((Integer) form.get(OFFSET_ATTR)); + public ContentAnalyticsQuery build(final Map parameters) { + this.scheme((String) parameters.getOrDefault(SCHEME_ATTR, DEFAULT_SCHEME)); + this.measures((String) parameters.get(MEASURES_ATTR)); + this.dimensions((String) parameters.get(DIMENSIONS_ATTR)); + this.timeDimensions((String) parameters.get(TIME_DIMENSIONS_ATTR)); + this.filters((String) parameters.get(FILTERS_ATTR)); + this.order((String) parameters.get(ORDER_ATTR)); + this.limit((Integer) parameters.get(LIMIT_ATTR)); + this.offset((Integer) parameters.get(OFFSET_ATTR)); return new ContentAnalyticsQuery(this); } + /** + * This method adds the default scheme to the terms if they don't contain it. + * + * @param terms The terms to check. + * + * @return The terms with the default scheme added if they don't contain it. + */ + private Set addScheme(final Set terms) { + return terms.stream() + .map(this::addScheme) + .collect(Collectors.toSet()); + } + + /** + * This method adds the default scheme to the term if it doesn't contain it. + * + * @param term The term to check. + * + * @return The term with the default scheme added if it doesn't contain it. + */ + private String addScheme(final String term) { + return term.contains(PERIOD) ? term : scheme + PERIOD + term; + } + } } diff --git a/dotCMS/src/test/java/com/dotcms/analytics/content/ContentAnalyticsQueryTest.java b/dotCMS/src/test/java/com/dotcms/analytics/content/ContentAnalyticsQueryTest.java new file mode 100644 index 000000000000..730ce7dfab87 --- /dev/null +++ b/dotCMS/src/test/java/com/dotcms/analytics/content/ContentAnalyticsQueryTest.java @@ -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 { + + /** + *
    + *
  • Method to test: {@link ContentAnalyticsQuery.Builder#build(Map)}
  • + * + *
  • Given Scenario: Transform the parameters of a CubeJS query as simple Strings + * into an actual CubeJS query.
  • + *
  • Expected Result: The generated CubeJS JSON query must contain the exact same + * parameters as the original ones, even if their formatting is different.
  • + *
+ */ + @SuppressWarnings("unchecked") + @Test + public void getCubeJSQueryFromSimpleString() throws IOException { + // ╔════════════════════════╗ + // ║ Generating Test Data ║ + // ╚════════════════════════╝ + final String testQueryParams = "{\n" + + " \"measures\": \"count,totalSessions\",\n" + + " \"dimensions\": \"host,whatAmI,url\",\n" + + " \"timeDimensions\": \"createdAt,day:Last month\",\n" + + " \"filters\": \"totalRequest gt 0,whatAmI contains PAGE||FILE\",\n" + + " \"order\": \"count asc,createdAt asc\",\n" + + " \"limit\": 5,\n" + + " \"offset\": 0\n" + + "}"; + final Map queryParamsMap = JsonUtil.getJsonFromString(testQueryParams); + + // ╔══════════════════╗ + // ║ Initialization ║ + // ╚══════════════════╝ + final ContentAnalyticsQuery contentAnalyticsQuery = + new ContentAnalyticsQuery.Builder().build(queryParamsMap); + final String cubeJsQuery = JsonUtil.getJsonStringFromObject(contentAnalyticsQuery); + final Map mapFromGeneratedQuery = JsonUtil.getJsonFromString(cubeJsQuery); + + // ╔══════════════╗ + // ║ Assertions ║ + // ╚══════════════╝ + // Checking measures + boolean expectedMeasures = false; + for (final String measure : contentAnalyticsQuery.measures()) { + expectedMeasures = ((List) 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) 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 timeDimension : contentAnalyticsQuery.timeDimensions()) { + final Map generatedTimeDimension = + ((List>) 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 filter : contentAnalyticsQuery.filters()) { + for (final Map generatedFilter : (List>) 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 generatedOrder : (List>)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"); + } + +} diff --git a/dotcms-postman/src/main/resources/postman/ContentTypeResourceTests.json b/dotcms-postman/src/main/resources/postman/ContentTypeResourceTests.json index 9bb863b3aeea..df4f9b8aaaa9 100644 --- a/dotcms-postman/src/main/resources/postman/ContentTypeResourceTests.json +++ b/dotcms-postman/src/main/resources/postman/ContentTypeResourceTests.json @@ -1,10 +1,9 @@ { "info": { - "_postman_id": "9f75619d-685f-4bdb-b16d-4baa2f6d62d6", + "_postman_id": "9aa702a3-2811-429a-a33c-480b75530b23", "name": "ContentType Resource", "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json", - "_exporter_id": "5403727", - "_collection_link": "https://cloudy-robot-285072.postman.co/workspace/JCastro-Workspace~5bfa586e-54db-429b-b7d5-c4ff997e3a0d/collection/5403727-9f75619d-685f-4bdb-b16d-4baa2f6d62d6?action=share&source=collection_link&creator=5403727" + "_exporter_id": "5403727" }, "item": [ { diff --git a/dotcms-postman/src/main/resources/postman/Content_Analytics.postman_collection.json b/dotcms-postman/src/main/resources/postman/Content_Analytics.postman_collection.json index 933b017a332c..dd472766efe5 100644 --- a/dotcms-postman/src/main/resources/postman/Content_Analytics.postman_collection.json +++ b/dotcms-postman/src/main/resources/postman/Content_Analytics.postman_collection.json @@ -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\": \"count,totalSessions\",\n \"dimensions\": \"host,whatAmI,url\",\n \"timeDimensions\": \"createdAt,day:Last month\",\n \"filters\": \"totalRequest gt 0,whatAmI contains PAGE||FILE\",\n \"order\": \"count asc,createdAt asc\",\n \"limit\": 5,\n \"offset\": 0\n}", "options": { "raw": { "language": "json" @@ -120,7 +120,7 @@ "response": [] } ], - "description": "This test group verifies that the Endpoint that receives simple String parameters for the Content Analytics query works as expected. This endpoint takes a JSON body with parameters such as the following:\n\n`{`\n\n`\"measures\": \"request.count request.totalSessions\",`\n\n`\"dimensions\": \"request.host request.whatAmI request.url\",`\n\n`\"timeDimensions\": \"request.createdAt:day:Last month\",`\n\n`\"filters\": \"request.totalRequest gt 0:request.whatAmI contains PAGE,FILE\",`\n\n`\"order\": \"request.count asc:request.createdAt asc\",`\n\n`\"limit\": 5,`\n\n`\"offset\": 0`\n\n`}`" + "description": "This test group verifies that the Endpoint that receives simple String parameters for the Content Analytics query works as expected. This endpoint takes a JSON body with parameters such as the following:\n\n`{`\n\n`\"measures\": \"count,totalSessions\",`\n\n`\"dimensions\": \"host,whatAmI,url\",`\n\n`\"timeDimensions\": \"createdAt,day:Last month\",`\n\n`\"filters\": \"totalRequest gt 0,whatAmI contains PAGE,FILE\",`\n\n`\"order\": \"count asc,createdAt asc\",`\n\n`\"limit\": 5,`\n\n`\"offset\": 0`\n\n`}`\n\nThe schema prefix for the appropriate terms is appended automatically by the service." }, { "name": "Using the JSON query",