orderByClauses = new ArrayList<>();
+ private Integer limit;
+ private Integer offset;
+
+ /**
+ * Represents a column in the SELECT clause of a SQL query. Handles column expressions with
+ * optional table prefix and column aliases.
+ *
+ * Examples:
+ *
+ *
{@code
+ * // Simple column with table prefix
+ * new Column("name", "u", null) -> "u.name"
+ *
+ * // Column with table prefix and alias
+ * new Column("first_name", "u", "name") -> "u.first_name AS name"
+ *
+ * // Aggregate function with alias
+ * new Column("COUNT(*)", null, "total") -> "COUNT(*) AS total"
+ *
+ * // Expression with alias
+ * new Column("COALESCE(name, 'Unknown')", "u", "display_name")
+ * -> "u.COALESCE(name, 'Unknown') AS display_name"
+ * }
+ */
+ public record Column(String expression, String tablePrefix, String alias) {
+ /**
+ * Creates a column with just an expression.
+ *
+ * @param expression the column expression
+ * @return a new Column without prefix or alias
+ */
+ public static Column of(String expression) {
+ return new Column(expression, null, null);
+ }
+
+ /**
+ * Creates a column with an expression and table prefix.
+ *
+ * @param expression the column expression
+ * @param tablePrefix the table prefix/alias
+ * @return a new Column with prefix
+ */
+ public static Column withPrefix(String expression, String tablePrefix) {
+ return new Column(expression, tablePrefix, null);
+ }
+
+ /**
+ * Creates a column with an expression and alias.
+ *
+ * @param expression the column expression
+ * @param alias the column alias
+ * @return a new Column with alias
+ */
+ public static Column withAlias(String expression, String alias) {
+ return new Column(expression, null, alias);
+ }
+
+ /**
+ * Converts the column definition to its SQL string representation.
+ *
+ * @return the SQL string representation of the column
+ */
+ public String toSql() {
+ StringBuilder sql = new StringBuilder();
+
+ // Add table prefix if present
+ if (tablePrefix != null && !tablePrefix.isEmpty()) {
+ sql.append(tablePrefix).append(".");
+ }
+
+ // Add the expression
+ sql.append(expression);
+
+ // Add alias if present
+ if (alias != null && !alias.isEmpty()) {
+ sql.append(" AS ").append(alias);
+ }
+
+ return sql.toString();
+ }
+ }
+
+ /**
+ * Represents a Common Table Expression (CTE). CTEs are temporary named result sets that exist for
+ * the duration of the query.
+ *
+ * Example:
+ *
+ *
{@code
+ * new CommonTableExpression("active_users",
+ * "SELECT id FROM users WHERE status = 'ACTIVE'")
+ * -> "active_users AS (
+ * SELECT id FROM users WHERE status = 'ACTIVE'
+ * )"
+ * }
+ */
+ public record CommonTableExpression(String name, String query) {
+ public String toSql() {
+ return name + " AS (\n" + query + "\n)";
+ }
+ }
+
+ /**
+ * Represents a LEFT JOIN clause. Includes the table name, alias, and join condition.
+ *
+ * Example:
+ *
+ *
{@code
+ * new Join("orders", "o", "o.user_id = u.id")
+ * -> "LEFT JOIN orders o ON o.user_id = u.id"
+ * }
+ */
+ public record Join(String table, String alias, String condition) {
+ public String toSql() {
+ return String.format("LEFT JOIN %s %s ON %s", table, alias, condition);
+ }
+ }
+
+ /**
+ * Represents an ORDER BY clause. Supports direction (ASC/DESC) and NULL handling (NULLS
+ * FIRST/LAST).
+ *
+ * Examples:
+ *
+ *
{@code
+ * new OrderByClause("name", "ASC", null) -> "name ASC"
+ * new OrderByClause("age", "DESC", "NULLS LAST") -> "age DESC NULLS LAST"
+ * new OrderByClause("status", null, "NULLS FIRST") -> "status NULLS FIRST"
+ * }
+ */
+ public record OrderByClause(String column, String direction, String nullHandling) {
+ public String toSql() {
+ StringBuilder sb = new StringBuilder(column);
+ if (direction != null) {
+ sb.append(" ").append(direction);
+ }
+ if (nullHandling != null) {
+ sb.append(" ").append(nullHandling);
+ }
+ return sb.toString();
+ }
+ }
+
+ /**
+ * Adds a Common Table Expression (CTE) to the query.
+ *
+ * @param name the name of the CTE
+ * @param query the SELECT query that defines the CTE
+ * @return this builder instance
+ * Example:
+ *
{@code
+ * builder.withCTE("active_users",
+ * "SELECT id FROM users WHERE status = 'ACTIVE'")
+ * }
+ */
+ public SelectBuilder withCTE(String name, String query) {
+ ctes.add(new CommonTableExpression(name, query));
+ return this;
+ }
+
+ /**
+ * Adds a column with table prefix.
+ *
+ * @param expression the column expression
+ * @param tablePrefix the table prefix/alias
+ * @return this builder instance
+ */
+ public SelectBuilder addColumn(String expression, String tablePrefix) {
+ columns.add(Column.withPrefix(expression, tablePrefix));
+ return this;
+ }
+
+ public List getColumnNames() {
+ return columns.stream().map(Column::expression).toList();
+ }
+
+ /**
+ * Adds a column with an alias.
+ *
+ * @param expression the column expression
+ * @param tablePrefix the table prefix/alias
+ * @param alias the column alias
+ * @return this builder instance
+ */
+ public SelectBuilder addColumn(String expression, String tablePrefix, String alias) {
+ columns.add(new Column(expression, tablePrefix, alias));
+ return this;
+ }
+
+ /**
+ * Adds a simple column without prefix or alias.
+ *
+ * @param expression the column expression
+ * @return this builder instance
+ */
+ public SelectBuilder addColumn(String expression) {
+ columns.add(Column.of(expression));
+ return this;
+ }
+
+ public SelectBuilder addColumnIfNotExist(String expression) {
+ String flattenedColumns =
+ columns.stream().map(Column::expression).collect(Collectors.joining(","));
+
+ if (!flattenedColumns.contains(unquote(expression))) {
+ columns.add(Column.of(expression));
+ }
+ return this;
+ }
+
+ /**
+ * Sets the FROM clause table without an alias.
+ *
+ * @param table the table name
+ * @return this builder instance
+ */
+ public SelectBuilder from(String table) {
+ this.fromTable = sanitizeFromClause(table);
+ return this;
+ }
+
+ /**
+ * Sets the FROM clause table with an alias.
+ *
+ * @param table the table name
+ * @param alias the table alias
+ * @return this builder instance
+ * Example:
+ *
{@code
+ * builder.from("users", "u")
+ * }
+ */
+ public SelectBuilder from(String table, String alias) {
+ this.fromTable = sanitizeFromClause(table);
+ ;
+ this.fromAlias = alias;
+ return this;
+ }
+
+ /**
+ * Adds a LEFT JOIN clause to the query.
+ *
+ * @param table the table to join
+ * @param alias the alias for the joined table
+ * @param condition the join condition builder
+ * @return this builder instance
+ * Example:
+ *
{@code
+ * builder.leftJoin("orders", "o",
+ * alias -> alias + ".user_id = u.id")
+ * }
+ */
+ public SelectBuilder leftJoin(String table, String alias, JoinCondition condition) {
+ joins.add(new Join(table, alias, condition.build(alias)));
+ return this;
+ }
+
+ /**
+ * Sets the WHERE clause condition.
+ *
+ * @param condition the WHERE condition
+ * @return this builder instance
+ * Example:
+ *
{@code
+ * builder.where(Condition.and(
+ * Condition.raw("active = true"),
+ * Condition.raw("age >= 18")
+ * ))
+ * }
+ */
+ public SelectBuilder where(Condition condition) {
+ this.whereCondition = condition;
+ return this;
+ }
+
+ /**
+ * Adds a HAVING clause condition. Multiple conditions are combined with AND.
+ *
+ * @param condition the HAVING condition
+ * @return this builder instance
+ * Example:
+ *
{@code
+ * builder.having(Condition.raw("COUNT(*) > 0"))
+ * }
+ */
+ public SelectBuilder having(Condition condition) {
+ havingConditions.add(condition);
+ return this;
+ }
+
+ /**
+ * Adds GROUP BY columns.
+ *
+ * @param columns the columns to group by
+ * @return this builder instance
+ * Example:
+ *
{@code
+ * builder.groupBy("department", "status")
+ * }
+ */
+ public SelectBuilder groupBy(String... columns) {
+ groupByClauses.addAll(Arrays.asList(columns));
+ return this;
+ }
+
+ /**
+ * Adds a GROUP BY column.
+ *
+ * @param column the column to group by
+ * @return this builder instance
+ */
+ public SelectBuilder groupBy(String column) {
+ groupByClauses.add(column);
+ return this;
+ }
+
+ /**
+ * Adds an ORDER BY clause with direction.
+ *
+ * @param column the column to sort by
+ * @param direction the sort direction ("ASC" or "DESC")
+ * @return this builder instance
+ */
+ public SelectBuilder orderBy(String column, String direction) {
+ return orderBy(column, direction, null);
+ }
+
+ /**
+ * Adds an ORDER BY clause with direction and NULL handling.
+ *
+ * @param column the column to sort by
+ * @param direction the sort direction ("ASC" or "DESC")
+ * @param nullHandling the NULL handling ("NULLS FIRST" or "NULLS LAST")
+ * @return this builder instance
+ * Example:
+ *
{@code
+ * builder.orderBy("last_updated", "DESC", "NULLS LAST")
+ * }
+ */
+ public SelectBuilder orderBy(String column, String direction, String nullHandling) {
+ orderByClauses.add(new OrderByClause(column, direction, nullHandling));
+ return this;
+ }
+
+ /**
+ * Parses and adds ORDER BY clauses from a raw SQL string. Handles complex expressions including
+ * CASE statements.
+ *
+ * @param rawSortClause the raw ORDER BY clause
+ * @return this builder instance
+ * Example:
+ *
{@code
+ * builder.orderBy("name ASC, created_at DESC NULLS LAST")
+ * builder.orderBy("CASE WHEN active THEN 1 ELSE 2 END DESC")
+ * }
+ */
+ public SelectBuilder orderBy(String rawSortClause) {
+ if (rawSortClause == null || rawSortClause.trim().isEmpty()) {
+ return this;
+ }
+
+ // Remove "order by" prefix if present
+ String cleaned = rawSortClause.trim().replaceFirst("(?i)^order\\s+by\\s+", "");
+
+ // Split by commas, but not commas within CASE statements
+ List parts = splitPreservingCaseStatements(cleaned);
+
+ for (String part : parts) {
+ String trimmed = part.trim();
+ if (!trimmed.isEmpty()) {
+ // Extract direction and null handling from the end
+ String[] directionParts = extractDirectionAndNulls(trimmed);
+ String column = directionParts[0];
+ String direction = directionParts[1];
+ String nullHandling = directionParts[2];
+
+ orderByClauses.add(new OrderByClause(column, direction, nullHandling));
+ }
+ }
+
+ return this;
+ }
+
+ /**
+ * Adds multiple ORDER BY clauses.
+ *
+ * @param clauses the list of ORDER BY clauses
+ * @return this builder instance
+ */
+ public SelectBuilder orderBy(List clauses) {
+ orderByClauses.addAll(clauses);
+ return this;
+ }
+
+ /**
+ * Sets the LIMIT clause with a maximum value of {@value DEFAULT_MAX_LIMIT}.
+ *
+ * @param limit the maximum number of rows to return
+ * @return this builder instance
+ */
+ public SelectBuilder limit(int limit) {
+ this.limit = Math.min(limit, DEFAULT_MAX_LIMIT);
+ return this;
+ }
+
+ /**
+ * Sets the LIMIT clause to the specified value plus one. Useful for detecting if there are more
+ * rows available.
+ *
+ * @param limit the base limit value
+ * @return this builder instance
+ */
+ public SelectBuilder limitPlusOne(int limit) {
+ this.limit = limit + 1;
+ return this;
+ }
+
+ /**
+ * Sets the LIMIT clause with a specified maximum value.
+ *
+ * @param limit the desired limit
+ * @param maxLimit the maximum allowed limit
+ * @return this builder instance
+ */
+ public SelectBuilder limitWithMax(int limit, int maxLimit) {
+ this.limit = Math.min(limit, maxLimit);
+ return this;
+ }
+
+ /**
+ * Sets the LIMIT clause to the minimum of the specified limit and maxLimit, plus one.
+ *
+ * @param limit the desired limit
+ * @param maxLimit the maximum allowed limit
+ * @return this builder instance
+ */
+ public SelectBuilder limitWithMaxPlusOne(int limit, int maxLimit) {
+ this.limit = Math.min(limit, maxLimit) + 1;
+ return this;
+ }
+
+ /**
+ * Sets the OFFSET clause.
+ *
+ * @param offset the number of rows to skip
+ * @return this builder instance
+ */
+ public SelectBuilder offset(int offset) {
+ this.offset = offset;
+ return this;
+ }
+
+ /**
+ * Builds the SQL query string with all keywords in lowercase.
+ *
+ * @return the complete SQL query string
+ */
+ public String build() {
+ return SqlFormatter.lowercase(buildQuery());
+ }
+
+ public String getWhereClause() {
+ return whereCondition.toSql();
+ }
+
+ /**
+ * Builds the SQL query string with formatting for readability.
+ *
+ * @return the formatted SQL query string
+ */
+ public String buildPretty() {
+ return SqlFormatter.prettyPrint(build());
+ }
+
+ private String buildQuery() {
+ StringBuilder sql = new StringBuilder();
+ appendCTEs(sql);
+ appendSelectClause(sql);
+ appendFromClause(sql);
+ appendJoins(sql);
+ appendWhereClause(sql);
+ appendGroupByClause(sql);
+ appendHavingClause(sql);
+ appendOrderByClause(sql);
+ appendPagination(sql);
+ return sql.toString();
+ }
+
+ private void appendCTEs(StringBuilder sql) {
+ if (ctes.isEmpty()) {
+ return;
+ }
+ sql.append("with ")
+ .append(ctes.stream().map(CommonTableExpression::toSql).collect(Collectors.joining(", ")))
+ .append(" ");
+ }
+
+ private void appendSelectClause(StringBuilder sql) {
+ sql.append("select ")
+ .append(columns.stream().map(Column::toSql).collect(Collectors.joining(", ")));
+ }
+
+ private void appendFromClause(StringBuilder sql) {
+ sql.append(" from ").append(fromTable);
+
+ if (fromAlias != null) {
+ sql.append(" as ").append(fromAlias);
+ }
+ }
+
+ private void appendJoins(StringBuilder sql) {
+ if (joins.isEmpty()) {
+ return;
+ }
+ sql.append(" ").append(joins.stream().map(Join::toSql).collect(Collectors.joining(" ")));
+ }
+
+ private void appendWhereClause(StringBuilder sql) {
+ if (whereCondition != null) {
+ String whereSql = whereCondition.toSql();
+ sql.append(whereSql.isEmpty() ? "" : " where " + whereSql);
+ }
+ }
+
+ private void appendGroupByClause(StringBuilder sql) {
+ if (groupByClauses.isEmpty()) {
+ return;
+ }
+ sql.append(" group by ").append(String.join(", ", groupByClauses));
+ }
+
+ private void appendHavingClause(StringBuilder sql) {
+ if (havingConditions.isEmpty()) {
+ return;
+ }
+ sql.append(" having ")
+ .append(
+ havingConditions.stream().map(Condition::toSql).collect(Collectors.joining(" and ")));
+ }
+
+ private void appendOrderByClause(StringBuilder sql) {
+ if (orderByClauses.isEmpty()) {
+ return;
+ }
+ sql.append(" order by ")
+ .append(
+ orderByClauses.stream().map(OrderByClause::toSql).collect(Collectors.joining(", ")));
+ }
+
+ private void appendPagination(StringBuilder sql) {
+ if (limit != null) {
+ sql.append(" limit ").append(limit);
+ }
+ if (offset != null) {
+ sql.append(" offset ").append(offset);
+ }
+ }
+
+ private List splitPreservingCaseStatements(String input) {
+ List results = new ArrayList<>();
+ StringBuilder current = new StringBuilder();
+ int depth = 0;
+ boolean inCase = false;
+
+ for (char c : input.toCharArray()) {
+ if (c == '(') {
+ depth++;
+ } else if (c == ')') {
+ depth--;
+ } else if (c == 'C' && current.toString().trim().isEmpty()) {
+ // Potential start of CASE
+ inCase = true;
+ } else if (inCase && current.toString().trim().endsWith("END")) {
+ // End of CASE statement
+ inCase = false;
+ }
+
+ if (c == ',' && depth == 0 && !inCase) {
+ results.add(current.toString());
+ current = new StringBuilder();
+ } else {
+ current.append(c);
+ }
+ }
+
+ if (!current.isEmpty()) {
+ results.add(current.toString());
+ }
+
+ return results;
+ }
+
+ private String[] extractDirectionAndNulls(String expr) {
+ String column = expr.trim();
+ String direction = null;
+ String nullHandling = null;
+
+ // Extract NULLS FIRST/LAST if present
+ String[] parts = extractNullHandling(column);
+ column = parts[0];
+ nullHandling = parts[1];
+
+ // Extract direction if present
+ parts = extractDirection(column);
+ column = parts[0];
+ direction = parts[1];
+
+ return new String[] {column, direction, nullHandling};
+ }
+
+ private String[] extractNullHandling(String expr) {
+ String column = expr;
+ String nullHandling = null;
+
+ String upperExpr = expr.toUpperCase();
+ if (upperExpr.endsWith("NULLS LAST") || upperExpr.endsWith("NULLS FIRST")) {
+ int nullsIndex = upperExpr.lastIndexOf("NULLS");
+ nullHandling = expr.substring(nullsIndex).trim();
+ column = expr.substring(0, nullsIndex).trim();
+ }
+
+ return new String[] {column, nullHandling};
+ }
+
+ private String[] extractDirection(String expr) {
+ String column = expr;
+ String direction = null;
+
+ int lastSpace = expr.lastIndexOf(' ');
+ if (lastSpace > 0) {
+ String lastWord = expr.substring(lastSpace + 1).trim().toUpperCase();
+ if (lastWord.equals("ASC") || lastWord.equals("DESC")) {
+ direction = lastWord;
+ column = expr.substring(0, lastSpace).trim();
+ }
+ }
+
+ return new String[] {column, direction};
+ }
+
+ /**
+ * Sanitizes the FROM clause by removing any leading or trailing "FROM" keyword.
+ *
+ * @param input the input string
+ * @return the sanitized string
+ */
+ private String sanitizeFromClause(String input) {
+ if (input == null) {
+ return null;
+ }
+
+ // Trim whitespace and remove leading/trailing "FROM" keyword (case-insensitive)
+ String sanitized = input.trim();
+ if (sanitized.toUpperCase().startsWith("FROM ")) {
+ sanitized = sanitized.substring(5).trim();
+ }
+ if (sanitized.toUpperCase().endsWith(" FROM")) {
+ sanitized = sanitized.substring(0, sanitized.length() - 5).trim();
+ }
+
+ return sanitized;
+ }
+
+ private static String unquote(String quoted) {
+ // Handle null or empty
+ if (quoted == null || quoted.isEmpty()) {
+ return "";
+ }
+
+ // Check minimum length (needs at least 2 chars for quotes)
+ if (quoted.length() < 2) {
+ return quoted;
+ }
+
+ char firstChar = quoted.charAt(0);
+ char lastChar = quoted.charAt(quoted.length() - 1);
+
+ // Check if quotes match
+ if ((firstChar == '"' && lastChar == '"') || (firstChar == '`' && lastChar == '`')) {
+ return quoted.substring(1, quoted.length() - 1);
+ }
+
+ return quoted;
+ }
+}
diff --git a/dhis-2/dhis-services/dhis-service-analytics/src/main/java/org/hisp/dhis/analytics/util/sql/SimpleCondition.java b/dhis-2/dhis-services/dhis-service-analytics/src/main/java/org/hisp/dhis/analytics/util/sql/SimpleCondition.java
new file mode 100644
index 000000000000..c8075c78fa6b
--- /dev/null
+++ b/dhis-2/dhis-services/dhis-service-analytics/src/main/java/org/hisp/dhis/analytics/util/sql/SimpleCondition.java
@@ -0,0 +1,63 @@
+/*
+ * Copyright (c) 2004-2025, University of Oslo
+ * All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ * Redistributions of source code must retain the above copyright notice, this
+ * list of conditions and the following disclaimer.
+ *
+ * Redistributions in binary form must reproduce the above copyright notice,
+ * this list of conditions and the following disclaimer in the documentation
+ * and/or other materials provided with the distribution.
+ * Neither the name of the HISP project nor the names of its contributors may
+ * be used to endorse or promote products derived from this software without
+ * specific prior written permission.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
+ * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+ * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+ * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
+ * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
+ * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
+ * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
+ * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+ * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+package org.hisp.dhis.analytics.util.sql;
+
+/**
+ * Represents a basic SQL condition string without any transformation. Unlike {@link Condition.Raw},
+ * this condition does not remove leading WHERE/AND keywords.
+ *
+ * Examples:
+ *
+ *
{@code
+ * // Basic conditions
+ * new SimpleCondition("active = true")
+ * -> "active = true"
+ *
+ * // Comparison operations
+ * new SimpleCondition("age >= 18")
+ * -> "age >= 18"
+ *
+ * // IN clauses
+ * new SimpleCondition("status IN ('ACTIVE', 'PENDING')")
+ * -> "status IN ('ACTIVE', 'PENDING')"
+ *
+ * // LIKE patterns
+ * new SimpleCondition("name LIKE 'John%'")
+ * -> "name LIKE 'John%'"
+ *
+ * // Complex conditions
+ * new SimpleCondition("(age >= 18 AND status = 'ACTIVE')")
+ * -> "(age >= 18 AND status = 'ACTIVE')"
+ * }
+ */
+public record SimpleCondition(String condition) implements Condition {
+ @Override
+ public String toSql() {
+ return condition;
+ }
+}
diff --git a/dhis-2/dhis-services/dhis-service-analytics/src/main/java/org/hisp/dhis/analytics/util/sql/SqlAliasReplacer.java b/dhis-2/dhis-services/dhis-service-analytics/src/main/java/org/hisp/dhis/analytics/util/sql/SqlAliasReplacer.java
new file mode 100644
index 000000000000..5069f06cbbec
--- /dev/null
+++ b/dhis-2/dhis-services/dhis-service-analytics/src/main/java/org/hisp/dhis/analytics/util/sql/SqlAliasReplacer.java
@@ -0,0 +1,142 @@
+/*
+ * Copyright (c) 2004-2025, University of Oslo
+ * All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ * Redistributions of source code must retain the above copyright notice, this
+ * list of conditions and the following disclaimer.
+ *
+ * Redistributions in binary form must reproduce the above copyright notice,
+ * this list of conditions and the following disclaimer in the documentation
+ * and/or other materials provided with the distribution.
+ * Neither the name of the HISP project nor the names of its contributors may
+ * be used to endorse or promote products derived from this software without
+ * specific prior written permission.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
+ * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+ * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+ * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
+ * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
+ * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
+ * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
+ * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+ * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+package org.hisp.dhis.analytics.util.sql;
+
+import java.util.List;
+import java.util.Set;
+import java.util.stream.Collectors;
+import lombok.experimental.UtilityClass;
+import net.sf.jsqlparser.expression.Expression;
+import net.sf.jsqlparser.expression.ExpressionVisitorAdapter;
+import net.sf.jsqlparser.expression.Function;
+import net.sf.jsqlparser.parser.CCJSqlParserUtil;
+import net.sf.jsqlparser.schema.Column;
+import net.sf.jsqlparser.schema.Table;
+import net.sf.jsqlparser.statement.select.PlainSelect;
+import net.sf.jsqlparser.statement.select.SelectExpressionItem;
+import net.sf.jsqlparser.statement.select.SubSelect;
+
+@UtilityClass
+public class SqlAliasReplacer {
+
+ public static String replaceTableAliases(String whereClause, List columns) {
+ if (whereClause == null || columns == null) {
+ throw new IllegalArgumentException("Where clause and columns list cannot be null");
+ }
+
+ if (whereClause.isEmpty() || columns.isEmpty()) {
+ return whereClause;
+ }
+
+ try {
+ Expression expr = CCJSqlParserUtil.parseCondExpression(whereClause);
+ ColumnReplacementVisitor visitor = new ColumnReplacementVisitor(columns);
+ expr.accept(visitor);
+ return expr.toString();
+ } catch (Exception e) {
+ throw new RuntimeException("Error parsing SQL where clause: " + e.getMessage(), e);
+ }
+ }
+
+ private static class ColumnReplacementVisitor extends ExpressionVisitorAdapter {
+ private final Set columns;
+ private static final Table PLACEHOLDER_TABLE = new Table("%s");
+ private boolean inSubQuery = false;
+
+ public ColumnReplacementVisitor(List columns) {
+ this.columns =
+ columns.stream()
+ .map(String::toLowerCase)
+ .map(this::stripQuotes)
+ .collect(Collectors.toSet());
+ }
+
+ @Override
+ public void visit(Column column) {
+ String columnName = column.getColumnName();
+ String rawColumnName = stripQuotes(columnName);
+
+ if (columns.contains(rawColumnName.toLowerCase())) {
+ String quoteType = getQuoteType(columnName);
+ if (!quoteType.isEmpty()) {
+ column.setColumnName(quoteType + rawColumnName + quoteType);
+ }
+ if (!inSubQuery) {
+ column.setTable(PLACEHOLDER_TABLE);
+ } else {
+ column.setTable(null);
+ }
+ }
+ }
+
+ @Override
+ public void visit(SubSelect subSelect) {
+ boolean wasInSubQuery = inSubQuery;
+ inSubQuery = true;
+
+ if (subSelect.getSelectBody() instanceof PlainSelect) {
+ PlainSelect plainSelect = (PlainSelect) subSelect.getSelectBody();
+ plainSelect
+ .getSelectItems()
+ .forEach(
+ selectItem -> {
+ if (selectItem instanceof SelectExpressionItem) {
+ SelectExpressionItem sei = (SelectExpressionItem) selectItem;
+ Expression expression = sei.getExpression();
+ if (expression instanceof Function) {
+ Function function = (Function) expression;
+ function.accept(this);
+ }
+ }
+ });
+ }
+
+ inSubQuery = wasInSubQuery;
+ }
+
+ private String stripQuotes(String identifier) {
+ if (identifier == null) return "";
+
+ if ((identifier.startsWith("\"") && identifier.endsWith("\""))
+ || (identifier.startsWith("`") && identifier.endsWith("`"))
+ || (identifier.startsWith("'") && identifier.endsWith("'"))) {
+ return identifier.substring(1, identifier.length() - 1);
+ }
+ return identifier;
+ }
+
+ private String getQuoteType(String identifier) {
+ if (identifier == null) return "";
+
+ if (identifier.startsWith("\"")) return "\"";
+ if (identifier.startsWith("`")) return "`";
+ if (identifier.startsWith("'")) return "'";
+ return "";
+ }
+ }
+}
diff --git a/dhis-2/dhis-services/dhis-service-analytics/src/main/java/org/hisp/dhis/analytics/util/sql/SqlColumnParser.java b/dhis-2/dhis-services/dhis-service-analytics/src/main/java/org/hisp/dhis/analytics/util/sql/SqlColumnParser.java
new file mode 100644
index 000000000000..b96b9f78b69d
--- /dev/null
+++ b/dhis-2/dhis-services/dhis-service-analytics/src/main/java/org/hisp/dhis/analytics/util/sql/SqlColumnParser.java
@@ -0,0 +1,89 @@
+/*
+ * Copyright (c) 2004-2025, University of Oslo
+ * All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ * Redistributions of source code must retain the above copyright notice, this
+ * list of conditions and the following disclaimer.
+ *
+ * Redistributions in binary form must reproduce the above copyright notice,
+ * this list of conditions and the following disclaimer in the documentation
+ * and/or other materials provided with the distribution.
+ * Neither the name of the HISP project nor the names of its contributors may
+ * be used to endorse or promote products derived from this software without
+ * specific prior written permission.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
+ * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+ * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+ * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
+ * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
+ * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
+ * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
+ * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+ * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+package org.hisp.dhis.analytics.util.sql;
+
+import lombok.experimental.UtilityClass;
+import net.sf.jsqlparser.expression.Expression;
+import net.sf.jsqlparser.parser.CCJSqlParserUtil;
+import net.sf.jsqlparser.schema.Column;
+
+@UtilityClass
+public class SqlColumnParser {
+
+ /**
+ * Removes table alias from a SQL column reference using JSqlParser. Handles quoted column names
+ * and complex SQL expressions.
+ *
+ * @param columnReference The SQL column reference (e.g., "ax.uidlevel2", "test1.`alfa`")
+ * @return The column name without the table alias (e.g., "uidlevel2", "alfa")
+ */
+ public static String removeTableAlias(String columnReference) {
+ if (columnReference == null || columnReference.isEmpty()) {
+ return columnReference;
+ }
+
+ try {
+ // Parse the column reference using JSqlParser
+ Expression expression = CCJSqlParserUtil.parseExpression(columnReference);
+
+ // Ensure the parsed expression is a Column
+ if (!(expression instanceof Column column)) {
+ throw new IllegalArgumentException(
+ "Input is not a valid SQL column reference: " + columnReference);
+ }
+
+ // Extract the column name
+ return unquote(column.getColumnName());
+ } catch (Exception e) {
+ throw new RuntimeException("Error parsing SQL: " + e.getMessage(), e);
+ }
+ }
+
+ // FIXME - this method is duplicated in SqlWhereClauseExtractor
+ private static String unquote(String quoted) {
+ // Handle null or empty
+ if (quoted == null || quoted.isEmpty()) {
+ return "";
+ }
+
+ // Check minimum length (needs at least 2 chars for quotes)
+ if (quoted.length() < 2) {
+ return quoted;
+ }
+
+ char firstChar = quoted.charAt(0);
+ char lastChar = quoted.charAt(quoted.length() - 1);
+
+ // Check if quotes match
+ if ((firstChar == '"' && lastChar == '"') || (firstChar == '`' && lastChar == '`')) {
+ return quoted.substring(1, quoted.length() - 1);
+ }
+
+ return quoted;
+ }
+}
diff --git a/dhis-2/dhis-services/dhis-service-analytics/src/main/java/org/hisp/dhis/analytics/util/sql/SqlConditionJoiner.java b/dhis-2/dhis-services/dhis-service-analytics/src/main/java/org/hisp/dhis/analytics/util/sql/SqlConditionJoiner.java
new file mode 100644
index 000000000000..af51d0cb1173
--- /dev/null
+++ b/dhis-2/dhis-services/dhis-service-analytics/src/main/java/org/hisp/dhis/analytics/util/sql/SqlConditionJoiner.java
@@ -0,0 +1,62 @@
+/*
+ * Copyright (c) 2004-2025, University of Oslo
+ * All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ * Redistributions of source code must retain the above copyright notice, this
+ * list of conditions and the following disclaimer.
+ *
+ * Redistributions in binary form must reproduce the above copyright notice,
+ * this list of conditions and the following disclaimer in the documentation
+ * and/or other materials provided with the distribution.
+ * Neither the name of the HISP project nor the names of its contributors may
+ * be used to endorse or promote products derived from this software without
+ * specific prior written permission.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
+ * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+ * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+ * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
+ * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
+ * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
+ * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
+ * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+ * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+package org.hisp.dhis.analytics.util.sql;
+
+public class SqlConditionJoiner {
+
+ public static String joinSqlConditions(String... conditions) {
+ if (conditions == null || conditions.length == 0) {
+ return "";
+ }
+
+ StringBuilder result = new StringBuilder("where ");
+ boolean firstCondition = true;
+
+ for (String condition : conditions) {
+ if (condition == null || condition.trim().isEmpty()) {
+ continue;
+ }
+
+ // Remove leading "where" or " where" and trim
+ String cleanedCondition = condition.trim();
+ if (cleanedCondition.toLowerCase().startsWith("where")) {
+ cleanedCondition = cleanedCondition.substring(5).trim();
+ }
+
+ if (!cleanedCondition.isEmpty()) {
+ if (!firstCondition) {
+ result.append(" and ");
+ }
+ result.append(cleanedCondition);
+ firstCondition = false;
+ }
+ }
+
+ return result.toString();
+ }
+}
diff --git a/dhis-2/dhis-services/dhis-service-analytics/src/main/java/org/hisp/dhis/analytics/util/sql/SqlFormatter.java b/dhis-2/dhis-services/dhis-service-analytics/src/main/java/org/hisp/dhis/analytics/util/sql/SqlFormatter.java
new file mode 100644
index 000000000000..fbc595c74001
--- /dev/null
+++ b/dhis-2/dhis-services/dhis-service-analytics/src/main/java/org/hisp/dhis/analytics/util/sql/SqlFormatter.java
@@ -0,0 +1,166 @@
+/*
+ * Copyright (c) 2004-2025, University of Oslo
+ * All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ * Redistributions of source code must retain the above copyright notice, this
+ * list of conditions and the following disclaimer.
+ *
+ * Redistributions in binary form must reproduce the above copyright notice,
+ * this list of conditions and the following disclaimer in the documentation
+ * and/or other materials provided with the distribution.
+ * Neither the name of the HISP project nor the names of its contributors may
+ * be used to endorse or promote products derived from this software without
+ * specific prior written permission.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
+ * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+ * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+ * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
+ * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
+ * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
+ * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
+ * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+ * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+package org.hisp.dhis.analytics.util.sql;
+
+import java.util.Set;
+
+public class SqlFormatter {
+ private static final Set MAIN_CLAUSES =
+ Set.of(
+ "with",
+ "select",
+ "from",
+ "left join",
+ "where",
+ "group by",
+ "having",
+ "order by",
+ "limit",
+ "offset");
+
+ private static final Set SQL_KEYWORDS =
+ Set.of(
+ "WITH",
+ "AS",
+ "SELECT",
+ "FROM",
+ "LEFT JOIN",
+ "ON",
+ "WHERE",
+ "GROUP BY",
+ "HAVING",
+ "ORDER BY",
+ "LIMIT",
+ "OFFSET",
+ "AND",
+ "OR",
+ "NOT",
+ "DESC",
+ "ASC",
+ "NULLS FIRST",
+ "NULLS LAST",
+ "CASE",
+ "WHEN",
+ "THEN",
+ "ELSE",
+ "END");
+
+ public static String prettyPrint(String sql) {
+ // First lowercase all SQL keywords
+ String formattedSql = lowercase(sql);
+
+ // Add newlines before main clauses
+ for (String clause : MAIN_CLAUSES) {
+ formattedSql = formattedSql.replace(" " + clause + " ", "\n" + clause + " ");
+ }
+
+ // Handle subqueries and CTEs
+ formattedSql = formatParentheses(formattedSql);
+
+ // Indent lines
+ String[] lines = formattedSql.split("\n");
+ StringBuilder result = new StringBuilder();
+ int indent = 0;
+
+ for (String line : lines) {
+ String trimmedLine = line.trim();
+ // Decrease indent if line starts with closing parenthesis
+ if (trimmedLine.startsWith(")")) {
+ indent--;
+ }
+ // Add indentation
+ result.append(" ".repeat(Math.max(0, indent))).append(trimmedLine).append("\n");
+ // Increase indent if line ends with opening parenthesis
+ if (trimmedLine.endsWith("(")) {
+ indent++;
+ }
+ }
+
+ return result.toString().trim();
+ }
+
+ /**
+ * Converts SQL keywords to lowercase and formats the SQL string into a single line. Preserves
+ * single spaces between words and removes extra whitespace.
+ *
+ * @param sql the SQL string to format
+ * @return formatted SQL string in a single line with lowercase keywords
+ */
+ public static String lowercase(String sql) {
+ String result = sql;
+
+ // Convert keywords to lowercase
+ for (String keyword : SQL_KEYWORDS) {
+ // Use word boundaries to only replace complete words
+ result = result.replaceAll("\\b" + keyword + "\\b", keyword.toLowerCase());
+ }
+
+ // Replace all whitespace sequences (including newlines) with a single space
+ result = result.replaceAll("\\s+", " ");
+
+ return result.trim();
+ }
+
+ private static String formatParentheses(String sql) {
+ StringBuilder result = new StringBuilder();
+ int indent = 0;
+ boolean inString = false;
+ char[] chars = sql.toCharArray();
+
+ for (int i = 0; i < chars.length; i++) {
+ char c = chars[i];
+
+ // Handle string literals
+ if (c == '\'') {
+ inString = !inString;
+ result.append(c);
+ continue;
+ }
+
+ if (!inString) {
+ if (c == '(') {
+ // Add newline and indent after opening parenthesis
+ result.append("(\n").append(" ".repeat(++indent));
+ continue;
+ } else if (c == ')') {
+ // Add newline and indent before closing parenthesis
+ result.append("\n").append(" ".repeat(--indent)).append(")");
+ continue;
+ } else if (c == ',') {
+ // Add newline after comma (for lists of columns, etc.)
+ result.append(",\n").append(" ".repeat(indent));
+ continue;
+ }
+ }
+
+ result.append(c);
+ }
+
+ return result.toString();
+ }
+}
diff --git a/dhis-2/dhis-services/dhis-service-analytics/src/main/java/org/hisp/dhis/analytics/util/sql/SqlWhereClauseExtractor.java b/dhis-2/dhis-services/dhis-service-analytics/src/main/java/org/hisp/dhis/analytics/util/sql/SqlWhereClauseExtractor.java
new file mode 100644
index 000000000000..73c27a92b693
--- /dev/null
+++ b/dhis-2/dhis-services/dhis-service-analytics/src/main/java/org/hisp/dhis/analytics/util/sql/SqlWhereClauseExtractor.java
@@ -0,0 +1,114 @@
+/*
+ * Copyright (c) 2004-2025, University of Oslo
+ * All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ * Redistributions of source code must retain the above copyright notice, this
+ * list of conditions and the following disclaimer.
+ *
+ * Redistributions in binary form must reproduce the above copyright notice,
+ * this list of conditions and the following disclaimer in the documentation
+ * and/or other materials provided with the distribution.
+ * Neither the name of the HISP project nor the names of its contributors may
+ * be used to endorse or promote products derived from this software without
+ * specific prior written permission.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
+ * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+ * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+ * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
+ * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
+ * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
+ * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
+ * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+ * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+package org.hisp.dhis.analytics.util.sql;
+
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import net.sf.jsqlparser.expression.BinaryExpression;
+import net.sf.jsqlparser.expression.Expression;
+import net.sf.jsqlparser.expression.Function;
+import net.sf.jsqlparser.expression.Parenthesis;
+import net.sf.jsqlparser.expression.operators.relational.Between;
+import net.sf.jsqlparser.expression.operators.relational.ExpressionList;
+import net.sf.jsqlparser.expression.operators.relational.InExpression;
+import net.sf.jsqlparser.expression.operators.relational.IsNullExpression;
+import net.sf.jsqlparser.parser.CCJSqlParserUtil;
+import net.sf.jsqlparser.statement.Statement;
+import net.sf.jsqlparser.statement.select.PlainSelect;
+import net.sf.jsqlparser.statement.select.Select;
+
+public class SqlWhereClauseExtractor {
+
+ // GIUSEPPE/MAIKEL: can we use a different approach to avoid using jsqlparser?
+ public static List extractWhereColumns(String sql) {
+ List columns = new ArrayList<>();
+ try {
+ // Parse the SQL string
+ Statement statement = CCJSqlParserUtil.parse(sql);
+ if (statement instanceof Select) {
+ PlainSelect plainSelect = (PlainSelect) ((Select) statement).getSelectBody();
+
+ // Get the WHERE clause
+ Expression whereExpression = plainSelect.getWhere();
+
+ // Extract columns from the WHERE clause
+ if (whereExpression != null) {
+ Set columnSet = new HashSet<>();
+ extractColumnsFromExpression(whereExpression, columnSet);
+ columns.addAll(columnSet);
+ }
+ }
+ } catch (Exception e) {
+ throw new RuntimeException("Error parsing SQL: " + e.getMessage(), e);
+ }
+ return columns;
+ }
+
+ private static void extractColumnsFromExpression(Expression expression, Set columns) {
+ if (expression instanceof net.sf.jsqlparser.schema.Column column) {
+ // Add the column name without table alias to the set
+ String columnName = column.getColumnName();
+ // Remove surrounding quotes if present and handle escaped quotes
+ if (columnName.startsWith("\"") && columnName.endsWith("\"")) {
+ columnName =
+ columnName
+ .substring(1, columnName.length() - 1)
+ .replace("\"\"", "\""); // Handle escaped quotes
+ }
+ columns.add(columnName);
+ } else if (expression instanceof BinaryExpression binaryExpression) {
+ // Recursively process left and right expressions
+ extractColumnsFromExpression(binaryExpression.getLeftExpression(), columns);
+ extractColumnsFromExpression(binaryExpression.getRightExpression(), columns);
+ } else if (expression instanceof InExpression inExpression) {
+ // Process the left expression of an IN clause
+ extractColumnsFromExpression(inExpression.getLeftExpression(), columns);
+ } else if (expression instanceof Parenthesis parenthesis) {
+ // Process the expression inside parentheses
+ extractColumnsFromExpression(parenthesis.getExpression(), columns);
+ } else if (expression instanceof IsNullExpression isNullExpression) {
+ // Process IS NULL expressions
+ extractColumnsFromExpression(isNullExpression.getLeftExpression(), columns);
+ } else if (expression instanceof Function function) {
+ // Process function parameters to extract column names
+ ExpressionList parameters = function.getParameters();
+ if (parameters != null) {
+ for (Expression parameter : parameters.getExpressions()) {
+ extractColumnsFromExpression(parameter, columns);
+ }
+ }
+ } else if (expression instanceof Between between) {
+ // Process BETWEEN expressions
+ extractColumnsFromExpression(between.getLeftExpression(), columns);
+ extractColumnsFromExpression(between.getBetweenExpressionStart(), columns);
+ extractColumnsFromExpression(between.getBetweenExpressionEnd(), columns);
+ }
+ }
+}
diff --git a/dhis-2/dhis-services/dhis-service-analytics/src/test/java/org/hisp/dhis/analytics/event/data/AbstractJdbcEventAnalyticsManagerTest.java b/dhis-2/dhis-services/dhis-service-analytics/src/test/java/org/hisp/dhis/analytics/event/data/AbstractJdbcEventAnalyticsManagerTest.java
index c78346771352..c74f08b63ce6 100644
--- a/dhis-2/dhis-services/dhis-service-analytics/src/test/java/org/hisp/dhis/analytics/event/data/AbstractJdbcEventAnalyticsManagerTest.java
+++ b/dhis-2/dhis-services/dhis-service-analytics/src/test/java/org/hisp/dhis/analytics/event/data/AbstractJdbcEventAnalyticsManagerTest.java
@@ -104,6 +104,7 @@
import org.hisp.dhis.program.Program;
import org.hisp.dhis.program.ProgramIndicator;
import org.hisp.dhis.program.ProgramIndicatorService;
+import org.hisp.dhis.setting.SystemSettingsService;
import org.hisp.dhis.system.grid.ListGrid;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -129,9 +130,11 @@ class AbstractJdbcEventAnalyticsManagerTest extends EventAnalyticsTest {
@Mock private OrganisationUnitService organisationUnitService;
+ @Mock private SystemSettingsService systemSettingsService;
+
@Spy
private ProgramIndicatorSubqueryBuilder programIndicatorSubqueryBuilder =
- new DefaultProgramIndicatorSubqueryBuilder(programIndicatorService);
+ new DefaultProgramIndicatorSubqueryBuilder(programIndicatorService, systemSettingsService);
@Spy private SqlBuilder sqlBuilder = new PostgreSqlBuilder();
diff --git a/dhis-2/dhis-services/dhis-service-analytics/src/test/java/org/hisp/dhis/analytics/event/data/EnrollmentAnalyticsManagerTest.java b/dhis-2/dhis-services/dhis-service-analytics/src/test/java/org/hisp/dhis/analytics/event/data/EnrollmentAnalyticsManagerTest.java
index a910ce147445..27b7344c906c 100644
--- a/dhis-2/dhis-services/dhis-service-analytics/src/test/java/org/hisp/dhis/analytics/event/data/EnrollmentAnalyticsManagerTest.java
+++ b/dhis-2/dhis-services/dhis-service-analytics/src/test/java/org/hisp/dhis/analytics/event/data/EnrollmentAnalyticsManagerTest.java
@@ -76,6 +76,8 @@
import org.hisp.dhis.relationship.RelationshipConstraint;
import org.hisp.dhis.relationship.RelationshipEntity;
import org.hisp.dhis.relationship.RelationshipType;
+import org.hisp.dhis.setting.SystemSettings;
+import org.hisp.dhis.setting.SystemSettingsService;
import org.hisp.dhis.system.grid.ListGrid;
import org.hisp.dhis.test.random.BeanRandomizer;
import org.junit.jupiter.api.BeforeEach;
@@ -110,10 +112,14 @@ class EnrollmentAnalyticsManagerTest extends EventAnalyticsTest {
@Spy private SqlBuilder sqlBuilder = new PostgreSqlBuilder();
+ @Mock private SystemSettingsService systemSettingsService;
+
@Spy
private EnrollmentTimeFieldSqlRenderer enrollmentTimeFieldSqlRenderer =
new EnrollmentTimeFieldSqlRenderer(sqlBuilder);
+ @Spy private SystemSettings systemSettings;
+
@Captor private ArgumentCaptor sql;
private String DEFAULT_COLUMNS =
@@ -128,9 +134,9 @@ class EnrollmentAnalyticsManagerTest extends EventAnalyticsTest {
@BeforeEach
public void setUp() {
when(jdbcTemplate.queryForRowSet(anyString())).thenReturn(this.rowSet);
-
+ when(systemSettingsService.getCurrentSettings()).thenReturn(systemSettings);
DefaultProgramIndicatorSubqueryBuilder programIndicatorSubqueryBuilder =
- new DefaultProgramIndicatorSubqueryBuilder(programIndicatorService);
+ new DefaultProgramIndicatorSubqueryBuilder(programIndicatorService, systemSettingsService);
subject =
new JdbcEnrollmentAnalyticsManager(
@@ -139,6 +145,7 @@ public void setUp() {
programIndicatorSubqueryBuilder,
enrollmentTimeFieldSqlRenderer,
executionPlanStore,
+ systemSettingsService,
sqlBuilder);
}
diff --git a/dhis-2/dhis-services/dhis-service-analytics/src/test/java/org/hisp/dhis/analytics/event/data/EventAnalyticsManagerTest.java b/dhis-2/dhis-services/dhis-service-analytics/src/test/java/org/hisp/dhis/analytics/event/data/EventAnalyticsManagerTest.java
index c310aea85254..f09065537227 100644
--- a/dhis-2/dhis-services/dhis-service-analytics/src/test/java/org/hisp/dhis/analytics/event/data/EventAnalyticsManagerTest.java
+++ b/dhis-2/dhis-services/dhis-service-analytics/src/test/java/org/hisp/dhis/analytics/event/data/EventAnalyticsManagerTest.java
@@ -86,6 +86,7 @@
import org.hisp.dhis.program.ProgramIndicator;
import org.hisp.dhis.program.ProgramIndicatorService;
import org.hisp.dhis.program.ProgramType;
+import org.hisp.dhis.setting.SystemSettingsService;
import org.hisp.dhis.system.grid.ListGrid;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -116,6 +117,8 @@ class EventAnalyticsManagerTest extends EventAnalyticsTest {
private static final String TABLE_NAME = "analytics_event";
+ @Mock private SystemSettingsService systemSettingsService;
+
private static final String DEFAULT_COLUMNS_WITH_REGISTRATION =
"event,ps,occurreddate,storedby,"
+ "createdbydisplayname"
@@ -129,7 +132,7 @@ public void setUp() {
EventTimeFieldSqlRenderer timeCoordinateSelector = new EventTimeFieldSqlRenderer(sqlBuilder);
ProgramIndicatorService programIndicatorService = mock(ProgramIndicatorService.class);
DefaultProgramIndicatorSubqueryBuilder programIndicatorSubqueryBuilder =
- new DefaultProgramIndicatorSubqueryBuilder(programIndicatorService);
+ new DefaultProgramIndicatorSubqueryBuilder(programIndicatorService, systemSettingsService);
subject =
new JdbcEventAnalyticsManager(
@@ -138,6 +141,7 @@ public void setUp() {
programIndicatorSubqueryBuilder,
timeCoordinateSelector,
executionPlanStore,
+ systemSettingsService,
sqlBuilder);
when(jdbcTemplate.queryForRowSet(anyString())).thenReturn(this.rowSet);
diff --git a/dhis-2/dhis-services/dhis-service-analytics/src/test/java/org/hisp/dhis/analytics/event/data/programindicator/ProgramIndicatorSubqueryBuilderTest.java b/dhis-2/dhis-services/dhis-service-analytics/src/test/java/org/hisp/dhis/analytics/event/data/programindicator/ProgramIndicatorSubqueryBuilderTest.java
index 0b8057d8ec3c..384d20a5731c 100644
--- a/dhis-2/dhis-services/dhis-service-analytics/src/test/java/org/hisp/dhis/analytics/event/data/programindicator/ProgramIndicatorSubqueryBuilderTest.java
+++ b/dhis-2/dhis-services/dhis-service-analytics/src/test/java/org/hisp/dhis/analytics/event/data/programindicator/ProgramIndicatorSubqueryBuilderTest.java
@@ -43,18 +43,24 @@
import org.hisp.dhis.program.ProgramIndicatorService;
import org.hisp.dhis.relationship.RelationshipEntity;
import org.hisp.dhis.relationship.RelationshipType;
+import org.hisp.dhis.setting.SystemSettings;
+import org.hisp.dhis.setting.SystemSettingsService;
import org.hisp.dhis.test.random.BeanRandomizer;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
+import org.mockito.Spy;
import org.mockito.junit.jupiter.MockitoExtension;
+import org.mockito.junit.jupiter.MockitoSettings;
+import org.mockito.quality.Strictness;
/**
* @author Luciano Fiandesio
*/
-@ExtendWith(MockitoExtension.class)
+@ExtendWith({MockitoExtension.class})
+@MockitoSettings(strictness = Strictness.LENIENT)
class ProgramIndicatorSubqueryBuilderTest {
private static final String DUMMY_EXPRESSION = "#{1234567}";
@@ -70,13 +76,18 @@ class ProgramIndicatorSubqueryBuilderTest {
@Mock private ProgramIndicatorService programIndicatorService;
+ @Mock private SystemSettingsService systemSettingsService;
+
@InjectMocks private DefaultProgramIndicatorSubqueryBuilder subject;
+ @Spy private SystemSettings systemSettings;
+
@BeforeEach
public void setUp() {
program = createProgram('A');
startDate = getDate(2018, 1, 1);
endDate = getDate(2018, 6, 30);
+ when(systemSettingsService.getCurrentSettings()).thenReturn(systemSettings);
}
@Test
diff --git a/dhis-2/dhis-services/dhis-service-analytics/src/test/java/org/hisp/dhis/analytics/table/setting/SqlBuilderSettingsTest.java b/dhis-2/dhis-services/dhis-service-analytics/src/test/java/org/hisp/dhis/analytics/table/setting/SqlBuilderSettingsTest.java
new file mode 100644
index 000000000000..eecfc05350bd
--- /dev/null
+++ b/dhis-2/dhis-services/dhis-service-analytics/src/test/java/org/hisp/dhis/analytics/table/setting/SqlBuilderSettingsTest.java
@@ -0,0 +1,91 @@
+/*
+ * Copyright (c) 2004-2024, University of Oslo
+ * All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ * Redistributions of source code must retain the above copyright notice, this
+ * list of conditions and the following disclaimer.
+ *
+ * Redistributions in binary form must reproduce the above copyright notice,
+ * this list of conditions and the following disclaimer in the documentation
+ * and/or other materials provided with the distribution.
+ * Neither the name of the HISP project nor the names of its contributors may
+ * be used to endorse or promote products derived from this software without
+ * specific prior written permission.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
+ * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+ * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+ * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
+ * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
+ * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
+ * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
+ * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+ * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+package org.hisp.dhis.analytics.table.setting;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.mockito.Mockito.when;
+
+import java.util.Set;
+import org.hisp.dhis.analytics.table.model.Skip;
+import org.hisp.dhis.external.conf.ConfigurationKey;
+import org.hisp.dhis.external.conf.DhisConfigurationProvider;
+import org.hisp.dhis.setting.SystemSettingsService;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+@ExtendWith(MockitoExtension.class)
+class SqlBuilderSettingsTest {
+ @Mock private DhisConfigurationProvider config;
+
+ @Mock private SystemSettingsService systemSettings;
+
+ @InjectMocks private AnalyticsTableSettings settings;
+
+ @Test
+ void testGetSkipIndexDimensionsDefault() {
+ when(config.getProperty(ConfigurationKey.ANALYTICS_TABLE_SKIP_INDEX))
+ .thenReturn(ConfigurationKey.ANALYTICS_TABLE_SKIP_INDEX.getDefaultValue());
+
+ assertEquals(Set.of(), settings.getSkipIndexDimensions());
+ }
+
+ @Test
+ void testGetSkipIndexDimensions() {
+ when(config.getProperty(ConfigurationKey.ANALYTICS_TABLE_SKIP_INDEX))
+ .thenReturn("kJ7yGrfR413, Hg5tGfr2fas , Ju71jG19Kaq,b5TgfRL9pUq");
+
+ assertEquals(
+ Set.of("kJ7yGrfR413", "Hg5tGfr2fas", "Ju71jG19Kaq", "b5TgfRL9pUq"),
+ settings.getSkipIndexDimensions());
+ }
+
+ @Test
+ void testGetSkipColumnDimensions() {
+ when(config.getProperty(ConfigurationKey.ANALYTICS_TABLE_SKIP_COLUMN))
+ .thenReturn("sixmonthlyapril, financialapril , financialjuly,financialnov");
+
+ assertEquals(
+ Set.of("sixmonthlyapril", "financialapril", "financialjuly", "financialnov"),
+ settings.getSkipColumnDimensions());
+ }
+
+ @Test
+ void testToSet() {
+ Set expected = Set.of("kJ7yGrfR413", "Hg5tGfr2fas", "Ju71jG19Kaq", "b5TgfRL9pUq");
+ assertEquals(expected, settings.toSet("kJ7yGrfR413, Hg5tGfr2fas , Ju71jG19Kaq,b5TgfRL9pUq"));
+ }
+
+ @Test
+ void testToSkip() {
+ assertEquals(Skip.INCLUDE, settings.toSkip(true));
+ assertEquals(Skip.SKIP, settings.toSkip(false));
+ }
+}
diff --git a/dhis-2/dhis-services/dhis-service-analytics/src/test/java/org/hisp/dhis/analytics/util/sql/SelectBuilderTest.java b/dhis-2/dhis-services/dhis-service-analytics/src/test/java/org/hisp/dhis/analytics/util/sql/SelectBuilderTest.java
new file mode 100644
index 000000000000..93d24d2bb4a9
--- /dev/null
+++ b/dhis-2/dhis-services/dhis-service-analytics/src/test/java/org/hisp/dhis/analytics/util/sql/SelectBuilderTest.java
@@ -0,0 +1,863 @@
+/*
+ * Copyright (c) 2004-2025, University of Oslo
+ * All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ * Redistributions of source code must retain the above copyright notice, this
+ * list of conditions and the following disclaimer.
+ *
+ * Redistributions in binary form must reproduce the above copyright notice,
+ * this list of conditions and the following disclaimer in the documentation
+ * and/or other materials provided with the distribution.
+ * Neither the name of the HISP project nor the names of its contributors may
+ * be used to endorse or promote products derived from this software without
+ * specific prior written permission.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
+ * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+ * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+ * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
+ * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
+ * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
+ * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
+ * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+ * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+package org.hisp.dhis.analytics.util.sql;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+
+@DisplayName("SelectBuilder")
+class SelectBuilderTest {
+
+ @Nested
+ @DisplayName("Basic SELECT queries")
+ class BasicSelectQueries {
+ @Test
+ @DisplayName("should build simple SELECT query")
+ void shouldBuildSimpleSelectQuery() {
+ String sql = new SelectBuilder().addColumn("name").from("users", "u").build();
+
+ assertEquals("select name from users as u", sql);
+ }
+
+ @Test
+ @DisplayName("should build SELECT with multiple columns")
+ void shouldBuildSelectWithMultipleColumns() {
+ String sql =
+ new SelectBuilder()
+ .addColumn("id")
+ .addColumn("name")
+ .addColumn("email", "", "email_address")
+ .addColumn("count(*)", "", "total")
+ .from("users", "u")
+ .build();
+
+ assertEquals(
+ "select id, name, email as email_address, count(*) as total from users as u", sql);
+ }
+ }
+
+ @Nested
+ @DisplayName("CTEs")
+ class CommonTableExpressions {
+ @Test
+ @DisplayName("should build query with single CTE")
+ void shouldBuildQueryWithSingleCTE() {
+ String sql =
+ new SelectBuilder()
+ .withCTE("user_counts", "select user_id, count(*) from events group by user_id")
+ .addColumn("u.name")
+ .addColumn("uc.count")
+ .from("users", "u")
+ .leftJoin("user_counts", "uc", alias -> alias + ".user_id = u.id")
+ .build();
+
+ assertEquals(
+ "with user_counts as ("
+ + " select user_id, count(*) from events group by user_id"
+ + " ) select u.name, uc.count from users as u left join user_counts uc on uc.user_id = u.id",
+ sql);
+ }
+
+ @Test
+ @DisplayName("should build query with multiple CTEs")
+ void shouldBuildQueryWithMultipleCTEs() {
+ String sql =
+ new SelectBuilder()
+ .withCTE("cte1", "select 1")
+ .withCTE("cte2", "select 2")
+ .addColumn("*")
+ .from("table", "t")
+ .build();
+
+ assertEquals("with cte1 as ( select 1 ), cte2 as ( select 2 ) select * from table as t", sql);
+ }
+ }
+
+ @Nested
+ @DisplayName("JOINs")
+ class Joins {
+ @Test
+ @DisplayName("should build query with single JOIN")
+ void shouldBuildQueryWithSingleJoin() {
+ String sql =
+ new SelectBuilder()
+ .addColumn("u.name")
+ .addColumn("o.total")
+ .from("users", "u")
+ .leftJoin("orders", "o", alias -> alias + ".user_id = u.id")
+ .build();
+
+ assertEquals(
+ "select u.name, o.total from users as u left join orders o on o.user_id = u.id", sql);
+ }
+
+ @Test
+ @DisplayName("should build query with multiple JOINs")
+ void shouldBuildQueryWithMultipleJoins() {
+ String sql =
+ new SelectBuilder()
+ .addColumn("u.name")
+ .addColumn("o.total")
+ .addColumn("a.address")
+ .from("users", "u")
+ .leftJoin("orders", "o", alias -> alias + ".user_id = u.id")
+ .leftJoin("addresses", "a", alias -> alias + ".user_id = u.id")
+ .build();
+
+ assertEquals(
+ "select u.name, o.total, a.address from users as u "
+ + "left join orders o on o.user_id = u.id "
+ + "left join addresses a on a.user_id = u.id",
+ sql);
+ }
+ }
+
+ @Nested
+ @DisplayName("WHERE conditions")
+ class WhereConditions {
+ @Test
+ @DisplayName("should build query with simple WHERE")
+ void shouldBuildQueryWithSimpleWhere() {
+ String sql =
+ new SelectBuilder()
+ .addColumn("name")
+ .from("users", "u")
+ .where(Condition.raw("active = true"))
+ .build();
+
+ assertEquals("select name from users as u where active = true", sql);
+ }
+
+ @Test
+ @DisplayName("should build query with AND conditions")
+ void shouldBuildQueryWithAndConditions() {
+ String sql =
+ new SelectBuilder()
+ .addColumn("name")
+ .from("users", "u")
+ .where(Condition.and(Condition.raw("active = true"), Condition.raw("age >= 18")))
+ .build();
+
+ assertEquals("select name from users as u where active = true and age >= 18", sql);
+ }
+ }
+
+ @Nested
+ @DisplayName("GROUP BY and HAVING")
+ class GroupByAndHaving {
+ @Test
+ @DisplayName("should build query with GROUP BY")
+ void shouldBuildQueryWithGroupBy() {
+ String sql =
+ new SelectBuilder()
+ .addColumn("department")
+ .addColumn("count(*)", "", "total")
+ .from("employees", "e")
+ .groupBy("department")
+ .build();
+
+ assertEquals(
+ "select department, count(*) as total from employees as e group by department", sql);
+ }
+
+ @Test
+ @DisplayName("should build query with GROUP BY and HAVING")
+ void shouldBuildQueryWithGroupByAndHaving() {
+ String sql =
+ new SelectBuilder()
+ .addColumn("department")
+ .addColumn("count(*)", "", "total")
+ .from("employees", "e")
+ .groupBy("department")
+ .having(Condition.raw("count(*) > 10"))
+ .build();
+
+ assertEquals(
+ "select department, count(*) as total from employees as e "
+ + "group by department having count(*) > 10",
+ sql);
+ }
+ }
+
+ @Nested
+ @DisplayName("Pagination")
+ class Pagination {
+ @Test
+ @DisplayName("should build query with LIMIT")
+ void shouldBuildQueryWithLimit() {
+ String sql = new SelectBuilder().addColumn("name").from("users", "u").limit(10).build();
+
+ assertEquals("select name from users as u limit 10", sql);
+ }
+
+ @Test
+ @DisplayName("should build query with LIMIT and OFFSET")
+ void shouldBuildQueryWithLimitAndOffset() {
+ String sql =
+ new SelectBuilder().addColumn("name").from("users", "u").limit(10).offset(20).build();
+
+ assertEquals("select name from users as u limit 10 offset 20", sql);
+ }
+
+ @Test
+ @DisplayName("should build query with LIMIT plus one")
+ void shouldBuildQueryWithLimitPlusOne() {
+ String sql =
+ new SelectBuilder().addColumn("name").from("users", "u").limitPlusOne(10).build();
+
+ assertEquals("select name from users as u limit 11", sql);
+ }
+
+ @Test
+ @DisplayName("should build query with max LIMIT")
+ void shouldBuildQueryWithMaxLimit() {
+ String sql =
+ new SelectBuilder().addColumn("name").from("users", "u").limitWithMax(100, 50).build();
+
+ assertEquals("select name from users as u limit 50", sql);
+ }
+ }
+
+ @Nested
+ @DisplayName("SQL keyword case handling")
+ class SqlKeywordCaseHandling {
+ @Test
+ @DisplayName("should lowercase CASE statement keywords")
+ void shouldLowerCaseCaseStatementKeywords() {
+ String sql =
+ new SelectBuilder()
+ .addColumn("CASE WHEN active THEN 'Active' ELSE 'Inactive' END", "", "status")
+ .from("users", "u")
+ .build();
+
+ assertEquals(
+ "select case when active then 'Active' else 'Inactive' end as status from users as u",
+ sql);
+ }
+
+ @Test
+ @DisplayName("should handle CASE statement in ORDER BY")
+ void shouldHandleCaseStatementInOrderBy() {
+ String sql =
+ new SelectBuilder()
+ .addColumn("name")
+ .from("users", "u")
+ .orderBy("CASE WHEN active THEN 1 ELSE 2 END ASC")
+ .build();
+
+ assertEquals(
+ "select name from users as u order by case when active then 1 else 2 end asc", sql);
+ }
+
+ @Test
+ @DisplayName("should handle multiple CASE statements")
+ void shouldHandleMultipleCaseStatements() {
+ String sql =
+ new SelectBuilder()
+ .addColumn("name")
+ .from("users", "u")
+ .orderBy(
+ "CASE WHEN active THEN 1 ELSE 2 END ASC, "
+ + "CASE WHEN status = 'VIP' THEN 1 ELSE 2 END DESC")
+ .build();
+
+ assertEquals(
+ "select name from users as u order by "
+ + "case when active then 1 else 2 end asc, "
+ + "case when status = 'VIP' then 1 else 2 end desc",
+ sql);
+ }
+
+ @Test
+ @DisplayName("should handle CASE statements with multiple WHEN clauses")
+ void shouldHandleCaseStatementsWithMultipleWhenClauses() {
+ String sql =
+ new SelectBuilder()
+ .addColumn("name")
+ .from("users", "u")
+ .orderBy(
+ "CASE "
+ + "WHEN status = 'ACTIVE' THEN 1 "
+ + "WHEN status = 'PENDING' THEN 2 "
+ + "ELSE 3 END DESC")
+ .build();
+
+ assertEquals(
+ "select name from users as u order by case "
+ + "when status = 'ACTIVE' then 1 "
+ + "when status = 'PENDING' then 2 "
+ + "else 3 end desc",
+ sql);
+ }
+ }
+
+ @Nested
+ @DisplayName("ORDER BY")
+ class OrderBy {
+ @Test
+ @DisplayName("should build query with simple ORDER BY")
+ void shouldBuildQueryWithSimpleOrderBy() {
+ String sql =
+ new SelectBuilder().addColumn("name").from("users", "u").orderBy("name", "asc").build();
+
+ assertEquals("select name from users as u order by name asc", sql);
+ }
+
+ @Test
+ @DisplayName("should build query with ORDER BY and NULL handling")
+ void shouldBuildQueryWithOrderByAndNullHandling() {
+ String sql =
+ new SelectBuilder()
+ .addColumn("name")
+ .from("users", "u")
+ .orderBy("name", "desc", "nulls last")
+ .build();
+
+ assertEquals("select name from users as u order by name desc nulls last", sql);
+ }
+
+ @Test
+ @DisplayName("should parse ORDER BY clause from string")
+ void shouldParseOrderByClauseFromString() {
+ String sql =
+ new SelectBuilder()
+ .addColumn("name")
+ .from("users", "u")
+ .orderBy("order by name desc nulls last, created_at asc")
+ .build();
+
+ assertEquals(
+ "select name from users as u order by name desc nulls last, created_at asc", sql);
+ }
+
+ @Test
+ @DisplayName("should correctly handle ASC keyword")
+ void shouldCorrectlyHandleAscKeyword() {
+ String sql = "SELECT * FROM users ORDER BY name ASC, description DESC";
+ String formatted = SqlFormatter.lowercase(sql);
+ assertEquals("select * from users order by name asc, description desc", formatted);
+ }
+
+ @Test
+ @DisplayName("should not affect words containing SQL keywords")
+ void shouldNotAffectWordsContainingKeywords() {
+ String sql = "SELECT description, ASCII(name) FROM users";
+ String formatted = SqlFormatter.lowercase(sql);
+ assertEquals("select description, ASCII(name) from users", formatted);
+ }
+
+ @Test
+ @DisplayName("should handle keywords at start and end of string")
+ void shouldHandleKeywordsAtBoundaries() {
+ String sql = "ASC name DESC";
+ String formatted = SqlFormatter.lowercase(sql);
+ assertEquals("asc name desc", formatted);
+ }
+ }
+
+ @Nested
+ @DisplayName("ORDER BY parsing")
+ class OrderByParsing {
+ @Test
+ @DisplayName("should handle simple direction")
+ void shouldHandleSimpleDirection() {
+ String sql =
+ new SelectBuilder()
+ .addColumn("name")
+ .from("users", "u")
+ .orderBy("last_updated DESC")
+ .build();
+
+ assertEquals("select name from users as u order by last_updated desc", sql);
+ }
+
+ @Test
+ @DisplayName("should handle NULLS LAST without direction")
+ void shouldHandleNullsLastWithoutDirection() {
+ String sql =
+ new SelectBuilder()
+ .addColumn("name")
+ .from("users", "u")
+ .orderBy("last_updated NULLS LAST")
+ .build();
+
+ assertEquals("select name from users as u order by last_updated nulls last", sql);
+ }
+
+ @Test
+ @DisplayName("should handle NULLS FIRST with direction")
+ void shouldHandleNullsFirstWithDirection() {
+ String sql =
+ new SelectBuilder()
+ .addColumn("name")
+ .from("users", "u")
+ .orderBy("last_updated DESC NULLS FIRST")
+ .build();
+
+ assertEquals("select name from users as u order by last_updated desc nulls first", sql);
+ }
+
+ @Test
+ @DisplayName("should handle multiple columns with different combinations")
+ void shouldHandleMultipleColumnsWithDifferentCombinations() {
+ String sql =
+ new SelectBuilder()
+ .addColumn("name")
+ .from("users", "u")
+ .orderBy("last_updated DESC NULLS LAST, created_at ASC, name DESC NULLS FIRST")
+ .build();
+
+ assertEquals(
+ "select name from users as u order by last_updated desc nulls last, "
+ + "created_at asc, name desc nulls first",
+ sql);
+ }
+
+ @Test
+ @DisplayName("should handle column name containing direction words")
+ void shouldHandleColumnNameContainingDirectionWords() {
+ String sql =
+ new SelectBuilder()
+ .addColumn("name")
+ .from("users", "u")
+ .orderBy("description_asc DESC")
+ .build();
+
+ assertEquals("select name from users as u order by description_asc desc", sql);
+ }
+ }
+
+ @Nested
+ @DisplayName("ORDER BY clause combinations")
+ class OrderByCombinations {
+ @Test
+ @DisplayName("should handle column only")
+ void shouldHandleColumnOnly() {
+ String sql =
+ new SelectBuilder().addColumn("name").from("users", "u").orderBy("last_updated").build();
+
+ assertEquals("select name from users as u order by last_updated", sql);
+ }
+
+ @Test
+ @DisplayName("should handle explicit ASC")
+ void shouldHandleExplicitAsc() {
+ String sql =
+ new SelectBuilder()
+ .addColumn("name")
+ .from("users", "u")
+ .orderBy("last_updated ASC")
+ .build();
+
+ assertEquals("select name from users as u order by last_updated asc", sql);
+ }
+
+ @Test
+ @DisplayName("should handle NULLS LAST without direction")
+ void shouldHandleNullsLastWithoutDirection() {
+ String sql =
+ new SelectBuilder()
+ .addColumn("name")
+ .from("users", "u")
+ .orderBy("last_updated NULLS LAST")
+ .build();
+
+ assertEquals("select name from users as u order by last_updated nulls last", sql);
+ }
+
+ @Test
+ @DisplayName("should handle NULLS FIRST without direction")
+ void shouldHandleNullsFirstWithoutDirection() {
+ String sql =
+ new SelectBuilder()
+ .addColumn("name")
+ .from("users", "u")
+ .orderBy("last_updated NULLS FIRST")
+ .build();
+
+ assertEquals("select name from users as u order by last_updated nulls first", sql);
+ }
+
+ @Test
+ @DisplayName("should handle multiple columns with different specifications")
+ void shouldHandleMultipleColumnsWithDifferentSpecifications() {
+ String sql =
+ new SelectBuilder()
+ .addColumn("name")
+ .from("users", "u")
+ .orderBy("status NULLS FIRST, created_at, updated_at DESC NULLS LAST")
+ .build();
+
+ assertEquals(
+ "select name from users as u order by status nulls first, "
+ + "created_at, updated_at desc nulls last",
+ sql);
+ }
+ }
+
+ @Nested
+ @DisplayName("ORDER BY raw strings")
+ class OrderByRawStrings {
+ @Test
+ @DisplayName("should handle ORDER BY with single column")
+ void shouldHandleOrderByWithSingleColumn() {
+ String sql =
+ new SelectBuilder()
+ .addColumn("name")
+ .from("users", "u")
+ .orderBy("created_at DESC")
+ .build();
+
+ assertEquals("select name from users as u order by created_at desc", sql);
+ }
+
+ @Test
+ @DisplayName("should handle ORDER BY with multiple columns")
+ void shouldHandleOrderByWithMultipleColumns() {
+ String sql =
+ new SelectBuilder()
+ .addColumn("name")
+ .from("users", "u")
+ .orderBy("last_name ASC, first_name DESC")
+ .build();
+
+ assertEquals("select name from users as u order by last_name asc, first_name desc", sql);
+ }
+
+ @Test
+ @DisplayName("should handle ORDER BY with NULLS handling")
+ void shouldHandleOrderByWithNullsHandling() {
+ String sql =
+ new SelectBuilder()
+ .addColumn("name")
+ .from("users", "u")
+ .orderBy("last_updated DESC NULLS LAST")
+ .build();
+
+ assertEquals("select name from users as u order by last_updated desc nulls last", sql);
+ }
+
+ @Test
+ @DisplayName("should handle ORDER BY with 'order by' prefix")
+ void shouldHandleOrderByWithPrefix() {
+ String sql =
+ new SelectBuilder()
+ .addColumn("name")
+ .from("users", "u")
+ .orderBy("ORDER BY created_at DESC")
+ .build();
+
+ assertEquals("select name from users as u order by created_at desc", sql);
+ }
+
+ @Test
+ @DisplayName("should handle empty ORDER BY")
+ void shouldHandleEmptyOrderBy() {
+ String sql = new SelectBuilder().addColumn("name").from("users", "u").orderBy("").build();
+
+ assertEquals("select name from users as u", sql);
+ }
+
+ @Test
+ @DisplayName("should handle null ORDER BY")
+ void shouldHandleNullOrderBy() {
+ String sql =
+ new SelectBuilder().addColumn("name").from("users", "u").orderBy((String) null).build();
+
+ assertEquals("select name from users as u", sql);
+ }
+
+ @Test
+ @DisplayName("should handle multiple ORDER BY calls")
+ void shouldHandleMultipleOrderByCalls() {
+ String sql =
+ new SelectBuilder()
+ .addColumn("name")
+ .from("users", "u")
+ .orderBy("last_name ASC")
+ .orderBy("first_name DESC")
+ .build();
+
+ assertEquals("select name from users as u order by last_name asc, first_name desc", sql);
+ }
+
+ @Test
+ @DisplayName("should handle complex ORDER BY expression")
+ void shouldHandleComplexOrderBy() {
+ String sql =
+ new SelectBuilder()
+ .addColumn("name")
+ .from("users", "u")
+ .orderBy("CASE WHEN active THEN 1 ELSE 2 END ASC, created_at DESC NULLS LAST")
+ .build();
+
+ assertEquals(
+ "select name from users as u order by case when active then 1 else 2 end asc, "
+ + "created_at desc nulls last",
+ sql);
+ }
+ }
+
+ @Nested
+ @DisplayName("WHERE raw conditions")
+ class WhereRawConditions {
+ @Test
+ @DisplayName("should handle raw WHERE condition")
+ void shouldHandleRawWhereCondition() {
+ String sql =
+ new SelectBuilder()
+ .addColumn("name")
+ .from("users", "u")
+ .where(Condition.raw("WHERE active = true"))
+ .build();
+
+ assertEquals("select name from users as u where active = true", sql);
+ }
+
+ @Test
+ @DisplayName("should handle raw WHERE with AND")
+ void shouldHandleRawWhereWithAnd() {
+ String sql =
+ new SelectBuilder()
+ .addColumn("name")
+ .from("users", "u")
+ .where(Condition.raw("WHERE active = true AND age >= 18"))
+ .build();
+
+ assertEquals("select name from users as u where active = true and age >= 18", sql);
+ }
+
+ @Test
+ @DisplayName("should clean WHERE prefix from raw condition")
+ void shouldCleanWherePrefixFromRawCondition() {
+ String sql =
+ new SelectBuilder()
+ .addColumn("name")
+ .from("users", "u")
+ .where(Condition.raw("WHERE status = 'ACTIVE'"))
+ .build();
+
+ assertEquals("select name from users as u where status = 'ACTIVE'", sql);
+ }
+
+ @Test
+ @DisplayName("should clean WHERE prefix from raw condition")
+ void shouldHandleRawWhereConditionWithNestedOr() {
+ String sql =
+ new SelectBuilder()
+ .addColumn("name")
+ .from("users", "u")
+ .where(
+ Condition.raw(
+ "WHERE ps = '12345' AND (status = 'ACTIVE' OR status = 'INACTIVE')"))
+ .build();
+
+ assertEquals(
+ "select name from users as u where ps = '12345' and (status = 'ACTIVE' or status = 'INACTIVE')",
+ sql);
+ }
+
+ @Test
+ @DisplayName("should handle multiple nested conditions with mixed operators")
+ void shouldHandleMultipleNestedConditions() {
+ String sql =
+ new SelectBuilder()
+ .addColumn("name")
+ .from("users", "u")
+ .where(
+ Condition.raw(
+ "WHERE (ps = '12345' OR ps = '67890') AND (status = 'ACTIVE' OR (status = 'INACTIVE' AND role = 'ADMIN'))"))
+ .build();
+
+ assertEquals(
+ "select name from users as u where (ps = '12345' or ps = '67890') and (status = 'ACTIVE' or (status = 'INACTIVE' and role = 'ADMIN'))",
+ sql);
+ }
+
+ @Test
+ @DisplayName("should handle complex conditions with NOT operator")
+ void shouldHandleComplexConditionsWithNot() {
+ String sql =
+ new SelectBuilder()
+ .addColumn("name")
+ .from("users", "u")
+ .where(
+ Condition.raw(
+ "WHERE NOT (ps = '12345' AND status = 'ACTIVE') OR (role = 'ADMIN' AND NOT status = 'INACTIVE')"))
+ .build();
+
+ assertEquals(
+ "select name from users as u where not (ps = '12345' and status = 'ACTIVE') or (role = 'ADMIN' and not status = 'INACTIVE')",
+ sql);
+ }
+
+ @Test
+ @DisplayName("should handle conditions with IN and BETWEEN operators")
+ void shouldHandleInAndBetweenOperators() {
+ String sql =
+ new SelectBuilder()
+ .addColumn("name")
+ .from("users", "u")
+ .where(
+ Condition.raw(
+ "WHERE ps IN ('12345', '67890') AND created_at BETWEEN '2023-01-01' AND '2023-12-31'"))
+ .build();
+
+ assertEquals(
+ "select name from users as u where ps IN ('12345', '67890') and created_at BETWEEN '2023-01-01' and '2023-12-31'",
+ sql);
+ }
+
+ @Test
+ @DisplayName("should handle conditions with LIKE and IS NULL operators")
+ void shouldHandleLikeAndIsNullOperators() {
+ String sql =
+ new SelectBuilder()
+ .addColumn("name")
+ .from("users", "u")
+ .where(Condition.raw("WHERE name LIKE '%John%' AND email IS NULL"))
+ .build();
+
+ assertEquals("select name from users as u where name LIKE '%John%' and email IS NULL", sql);
+ }
+
+ @Test
+ @DisplayName("should handle conditions with subqueries")
+ void shouldHandleConditionsWithSubqueries() {
+ String sql =
+ new SelectBuilder()
+ .addColumn("name")
+ .from("users", "u")
+ .where(
+ Condition.raw(
+ "WHERE ps = (SELECT ps FROM profiles WHERE user_id = u.id) AND status = 'ACTIVE'"))
+ .build();
+
+ assertEquals(
+ "select name from users as u where ps = (select ps from profiles where user_id = u.id) and status = 'ACTIVE'",
+ sql);
+ }
+
+ @Test
+ @DisplayName("should clean AND prefix from raw condition")
+ void shouldCleanAndPrefixFromRawCondition() {
+ String sql =
+ new SelectBuilder()
+ .addColumn("name")
+ .from("users", "u")
+ .where(Condition.raw("AND status = 'ACTIVE'"))
+ .build();
+
+ assertEquals("select name from users as u where status = 'ACTIVE'", sql);
+ }
+ }
+
+ @Nested
+ @DisplayName("Mixed raw and structured conditions")
+ class MixedConditions {
+ @Test
+ @DisplayName("should handle mix of raw and structured ORDER BY")
+ void shouldHandleMixedOrderBy() {
+ String sql =
+ new SelectBuilder()
+ .addColumn("name")
+ .from("users", "u")
+ .orderBy("last_name ASC")
+ .orderBy("created_at", "DESC", "NULLS LAST")
+ .build();
+
+ assertEquals(
+ "select name from users as u order by last_name asc, created_at desc nulls last", sql);
+ }
+
+ @Test
+ @DisplayName("should handle mix of raw and structured WHERE conditions")
+ void shouldHandleMixedWhereConditions() {
+ String sql =
+ new SelectBuilder()
+ .addColumn("name")
+ .from("users", "u")
+ .where(Condition.and(Condition.raw("status = 'ACTIVE'"), Condition.raw("age >= 18")))
+ .build();
+
+ assertEquals("select name from users as u where status = 'ACTIVE' and age >= 18", sql);
+ }
+
+ @Test
+ @DisplayName("should handle conditions with CASE statements")
+ void shouldHandleConditionsWithCaseStatements() {
+ String sql =
+ new SelectBuilder()
+ .addColumn("name")
+ .from("users", "u")
+ .where(
+ Condition.raw(
+ "WHERE CASE WHEN status = 'ACTIVE' THEN ps = '12345' ELSE ps = '67890' END"))
+ .build();
+
+ assertEquals(
+ "select name from users as u where case when status = 'ACTIVE' then ps = '12345' else ps = '67890' end",
+ sql);
+ }
+
+ @Test
+ @DisplayName("should handle conditions with EXISTS operator")
+ void shouldHandleConditionsWithExists() {
+ String sql =
+ new SelectBuilder()
+ .addColumn("name")
+ .from("users", "u")
+ .where(Condition.raw("WHERE EXISTS (SELECT 1 FROM profiles WHERE user_id = u.id)"))
+ .build();
+
+ assertEquals(
+ "select name from users as u where EXISTS (select 1 from profiles where user_id = u.id)",
+ sql);
+ }
+
+ @Test
+ @DisplayName("should handle complex parentheses grouping")
+ void shouldHandleComplexParenthesesGrouping() {
+ String sql =
+ new SelectBuilder()
+ .addColumn("name")
+ .from("users", "u")
+ .where(
+ Condition.raw(
+ "WHERE (ps = '12345' OR (status = 'ACTIVE' AND role = 'ADMIN')) AND (created_at > '2023-01-01' OR updated_at < '2023-12-31')"))
+ .build();
+
+ assertEquals(
+ "select name from users as u where (ps = '12345' or (status = 'ACTIVE' and role = 'ADMIN')) and (created_at > '2023-01-01' or updated_at < '2023-12-31')",
+ sql);
+ }
+ }
+}
diff --git a/dhis-2/dhis-services/dhis-service-analytics/src/test/java/org/hisp/dhis/analytics/util/sql/SqlAliasReplacerTest.java b/dhis-2/dhis-services/dhis-service-analytics/src/test/java/org/hisp/dhis/analytics/util/sql/SqlAliasReplacerTest.java
new file mode 100644
index 000000000000..2af5bf53fcad
--- /dev/null
+++ b/dhis-2/dhis-services/dhis-service-analytics/src/test/java/org/hisp/dhis/analytics/util/sql/SqlAliasReplacerTest.java
@@ -0,0 +1,251 @@
+/*
+ * Copyright (c) 2004-2025, University of Oslo
+ * All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ * Redistributions of source code must retain the above copyright notice, this
+ * list of conditions and the following disclaimer.
+ *
+ * Redistributions in binary form must reproduce the above copyright notice,
+ * this list of conditions and the following disclaimer in the documentation
+ * and/or other materials provided with the distribution.
+ * Neither the name of the HISP project nor the names of its contributors may
+ * be used to endorse or promote products derived from this software without
+ * specific prior written permission.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
+ * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+ * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+ * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
+ * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
+ * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
+ * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
+ * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+ * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+package org.hisp.dhis.analytics.util.sql;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.util.Arrays;
+import java.util.List;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+class SqlAliasReplacerTest {
+
+ @Test
+ @DisplayName("Should handle columns without aliases")
+ void testColumnsWithoutAliases() {
+ List columns = Arrays.asList("employee", "country");
+ String input = "employee = 10 and country = 'IT'";
+ String expected = "%s.employee = 10 AND %s.country = 'IT'";
+ assertEquals(expected, SqlAliasReplacer.replaceTableAliases(input, columns));
+ }
+
+ @Test
+ @DisplayName("Should handle mix of aliased and non-aliased columns")
+ void testMixedAliasedAndNonAliasedColumns() {
+ List columns = Arrays.asList("employee", "country", "status");
+ String input = "employee = 10 and ax.country = 'IT' and status = 'ACTIVE'";
+ String expected = "%s.employee = 10 AND %s.country = 'IT' AND %s.status = 'ACTIVE'";
+ assertEquals(expected, SqlAliasReplacer.replaceTableAliases(input, columns));
+ }
+
+ @Test
+ @DisplayName("Should handle case insensitive column names")
+ void testCaseInsensitiveColumns() {
+ List columns = Arrays.asList("Employee", "COUNTRY");
+ String input = "employee = 10 and country = 'IT'";
+ String expected = "%s.employee = 10 AND %s.country = 'IT'";
+ assertEquals(expected, SqlAliasReplacer.replaceTableAliases(input, columns));
+ }
+
+ @Test
+ @DisplayName("Should not modify non-column words")
+ void testNonColumnWords() {
+ List columns = Arrays.asList("employee", "country");
+ String input = "employee = 10 and country = 'IT' and status = 'ACTIVE'";
+ String expected = "%s.employee = 10 AND %s.country = 'IT' AND status = 'ACTIVE'";
+ assertEquals(expected, SqlAliasReplacer.replaceTableAliases(input, columns));
+ }
+
+ @Test
+ @DisplayName("Should handle complex conditions with functions")
+ void testComplexConditionsWithFunctions() {
+ List columns = Arrays.asList("date", "amount");
+ String input = "EXTRACT(YEAR FROM date) = 2023 and COALESCE(amount, 0) > 100";
+ String expected = "EXTRACT(YEAR FROM %s.date) = 2023 AND COALESCE(%s.amount, 0) > 100";
+ assertEquals(expected, SqlAliasReplacer.replaceTableAliases(input, columns));
+ }
+
+ @Test
+ @DisplayName("Should handle quoted column names")
+ void testQuotedColumnNames() {
+ List columns = Arrays.asList("employee", "country");
+ String input = "\"employee\" = 10 and `country` = 'IT'";
+ String expected = "%s.\"employee\" = 10 AND %s.`country` = 'IT'";
+ assertEquals(expected, SqlAliasReplacer.replaceTableAliases(input, columns));
+ }
+
+ @Test
+ @DisplayName("Should handle OR conditions")
+ void testOrConditions() {
+ List columns = Arrays.asList("employee", "country", "age");
+ String input = "employee = 10 OR ax.country = 'IT' OR age > 25";
+ String expected = "%s.employee = 10 OR %s.country = 'IT' OR %s.age > 25";
+ assertEquals(expected, SqlAliasReplacer.replaceTableAliases(input, columns));
+ }
+
+ @Test
+ @DisplayName("Should handle mixed AND/OR conditions with parentheses")
+ void testMixedConditionsWithParentheses() {
+ List columns = Arrays.asList("employee", "country", "age", "salary");
+ String input = "(employee = 10 OR ax.country = 'IT') AND (age > 25 OR by.salary >= 50000)";
+ String expected =
+ "(%s.employee = 10 OR %s.country = 'IT') AND (%s.age > 25 OR %s.salary >= 50000)";
+ assertEquals(expected, SqlAliasReplacer.replaceTableAliases(input, columns));
+ }
+
+ @Test
+ @DisplayName("Should handle various SQL functions")
+ void testVariousSqlFunctions() {
+ List columns = Arrays.asList("date", "name", "salary");
+ String input =
+ "UPPER(name) = 'JOHN' AND DATE_TRUNC('month', ax.date) = '2023-01-01' AND ABS(by.salary) > 1000";
+ String expected =
+ "UPPER(%s.name) = 'JOHN' AND DATE_TRUNC('month', %s.date) = '2023-01-01' AND ABS(%s.salary) > 1000";
+ assertEquals(expected, SqlAliasReplacer.replaceTableAliases(input, columns));
+ }
+
+ @Test
+ @DisplayName("Should handle nested functions")
+ void testNestedFunctions() {
+ List columns = Arrays.asList("date", "amount");
+ String input =
+ "ROUND(COALESCE(ax.amount, 0) * 100, 2) > 1000 AND EXTRACT(YEAR FROM DATE_TRUNC('month', date)) = 2023";
+ String expected =
+ "ROUND(COALESCE(%s.amount, 0) * 100, 2) > 1000 AND EXTRACT(YEAR FROM DATE_TRUNC('month', %s.date)) = 2023";
+ assertEquals(expected, SqlAliasReplacer.replaceTableAliases(input, columns));
+ }
+
+ @Test
+ @DisplayName("Should handle IN conditions")
+ void testInConditions() {
+ List columns = Arrays.asList("country", "status");
+ String input = "ax.country IN ('US', 'UK', 'IT') AND status IN ('ACTIVE', 'PENDING')";
+ String expected = "%s.country IN ('US', 'UK', 'IT') AND %s.status IN ('ACTIVE', 'PENDING')";
+ assertEquals(expected, SqlAliasReplacer.replaceTableAliases(input, columns));
+ }
+
+ @Test
+ @DisplayName("Should handle BETWEEN conditions")
+ void testBetweenConditions() {
+ List columns = Arrays.asList("date", "amount");
+ String input = "ax.date BETWEEN '2023-01-01' AND '2023-12-31' AND amount BETWEEN 100 AND 1000";
+ String expected =
+ "%s.date BETWEEN '2023-01-01' AND '2023-12-31' AND %s.amount BETWEEN 100 AND 1000";
+ assertEquals(expected, SqlAliasReplacer.replaceTableAliases(input, columns));
+ }
+
+ @Test
+ @DisplayName("Should handle NULL comparisons")
+ void testNullComparisons() {
+ List columns = Arrays.asList("name", "date");
+ String input = "ax.name IS NULL AND date IS NOT NULL";
+ String expected = "%s.name IS NULL AND %s.date IS NOT NULL";
+ assertEquals(expected, SqlAliasReplacer.replaceTableAliases(input, columns));
+ }
+
+ @Test
+ @DisplayName("Should handle LIKE conditions")
+ void testLikeConditions() {
+ List columns = Arrays.asList("name", "email");
+ String input = "ax.name LIKE 'John%' AND email NOT LIKE '%test%'";
+ String expected = "%s.name LIKE 'John%' AND %s.email NOT LIKE '%test%'";
+ assertEquals(expected, SqlAliasReplacer.replaceTableAliases(input, columns));
+ }
+
+ @Test
+ @DisplayName("Should handle mathematical expressions")
+ void testMathematicalExpressions() {
+ List columns = Arrays.asList("price", "quantity", "discount");
+ String input = "ax.price * by.quantity * (1 - discount/100) > 1000";
+ String expected = "%s.price * %s.quantity * (1 - %s.discount / 100) > 1000";
+ assertEquals(expected, SqlAliasReplacer.replaceTableAliases(input, columns));
+ }
+
+ @Test
+ @DisplayName("Should handle subqueries in conditions")
+ void testSubqueries() {
+ List columns = Arrays.asList("department_id", "salary");
+ String input =
+ "ax.department_id IN (SELECT id FROM departments) AND salary > (SELECT AVG(by.salary) FROM employees)";
+ String expected =
+ "%s.department_id IN (SELECT id FROM departments) AND %s.salary > (SELECT AVG(salary) FROM employees)";
+ assertEquals(expected, SqlAliasReplacer.replaceTableAliases(input, columns));
+ }
+
+ @Test
+ @DisplayName("Should handle CASE expressions")
+ void testCaseExpressions() {
+ List columns = Arrays.asList("status", "amount");
+ String input = "CASE WHEN ax.status = 'ACTIVE' THEN by.amount * 1.1 ELSE amount END > 1000";
+ String expected =
+ "CASE WHEN %s.status = 'ACTIVE' THEN %s.amount * 1.1 ELSE %s.amount END > 1000";
+ assertEquals(expected, SqlAliasReplacer.replaceTableAliases(input, columns));
+ }
+
+ @Test
+ @DisplayName("Should handle special characters in column names")
+ void testSpecialCharactersInColumnNames() {
+ List columns = Arrays.asList("first_name", "last_name", "email_address");
+ String input =
+ "ax.\"first_name\" = 'John' AND by.`last_name` = 'Doe' AND \"email_address\" LIKE '%@%'";
+ String expected =
+ "%s.\"first_name\" = 'John' AND %s.`last_name` = 'Doe' AND %s.\"email_address\" LIKE '%@%'";
+ assertEquals(expected, SqlAliasReplacer.replaceTableAliases(input, columns));
+ }
+
+ @Test
+ @DisplayName("Should handle nested functions in subqueries")
+ void testNestedFunctionsInSubqueries() {
+ List columns = Arrays.asList("salary", "bonus");
+ String input = "salary > (SELECT MAX(COALESCE(ax.salary + by.bonus, 0)) FROM employees)";
+ String expected = "%s.salary > (SELECT MAX(COALESCE(salary + bonus, 0)) FROM employees)";
+ assertEquals(expected, SqlAliasReplacer.replaceTableAliases(input, columns));
+ }
+
+ @Test
+ @DisplayName("Should handle multiple subqueries with functions")
+ void testMultipleSubqueriesWithFunctions() {
+ List columns = Arrays.asList("salary", "age");
+ String input =
+ "ax.salary > (SELECT AVG(by.salary) FROM emp1) AND age < (SELECT MAX(cx.age) FROM emp2)";
+ String expected =
+ "%s.salary > (SELECT AVG(salary) FROM emp1) AND %s.age < (SELECT MAX(age) FROM emp2)";
+ assertEquals(expected, SqlAliasReplacer.replaceTableAliases(input, columns));
+ }
+
+ @Test
+ @DisplayName("Should handle subqueries with multiple function arguments")
+ void testSubqueriesWithMultipleFunctionArguments() {
+ List columns = Arrays.asList("salary", "bonus", "tax");
+ String input = "ax.salary > (SELECT SUM(by.salary + cx.bonus - dx.tax) FROM employees)";
+ String expected = "%s.salary > (SELECT SUM(salary + bonus - tax) FROM employees)";
+ assertEquals(expected, SqlAliasReplacer.replaceTableAliases(input, columns));
+ }
+
+ @Test
+ @DisplayName("Should handle subqueries with conditional functions")
+ void testSubqueriesWithConditionalFunctions() {
+ List columns = Arrays.asList("salary", "status");
+ String input =
+ "ax.salary > (SELECT AVG(CASE WHEN by.status = 'ACTIVE' THEN cx.salary ELSE 0 END) FROM employees)";
+ String expected =
+ "%s.salary > (SELECT AVG(CASE WHEN status = 'ACTIVE' THEN salary ELSE 0 END) FROM employees)";
+ assertEquals(expected, SqlAliasReplacer.replaceTableAliases(input, columns));
+ }
+}
diff --git a/dhis-2/dhis-services/dhis-service-analytics/src/test/java/org/hisp/dhis/analytics/util/sql/SqlColumnParserTest.java b/dhis-2/dhis-services/dhis-service-analytics/src/test/java/org/hisp/dhis/analytics/util/sql/SqlColumnParserTest.java
new file mode 100644
index 000000000000..e003701d30fd
--- /dev/null
+++ b/dhis-2/dhis-services/dhis-service-analytics/src/test/java/org/hisp/dhis/analytics/util/sql/SqlColumnParserTest.java
@@ -0,0 +1,82 @@
+/*
+ * Copyright (c) 2004-2025, University of Oslo
+ * All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ * Redistributions of source code must retain the above copyright notice, this
+ * list of conditions and the following disclaimer.
+ *
+ * Redistributions in binary form must reproduce the above copyright notice,
+ * this list of conditions and the following disclaimer in the documentation
+ * and/or other materials provided with the distribution.
+ * Neither the name of the HISP project nor the names of its contributors may
+ * be used to endorse or promote products derived from this software without
+ * specific prior written permission.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
+ * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+ * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+ * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
+ * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
+ * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
+ * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
+ * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+ * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+package org.hisp.dhis.analytics.util.sql;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import org.junit.jupiter.api.Test;
+
+class SqlColumnParserTest {
+ @Test
+ void testRemoveTableAlias_WithDoubleQuotes() throws Exception {
+ String result = SqlColumnParser.removeTableAlias("ax.\"uidlevel2\"");
+ assertEquals("uidlevel2", result);
+ }
+
+ @Test
+ void testRemoveTableAlias_WithBackticks() throws Exception {
+ String result = SqlColumnParser.removeTableAlias("cc.`alfa`");
+ assertEquals("alfa", result);
+ }
+
+ @Test
+ void testRemoveTableAlias_WithoutQuotes() throws Exception {
+ String result = SqlColumnParser.removeTableAlias("test1.uidlevel2");
+ assertEquals("uidlevel2", result);
+ }
+
+ @Test
+ void testRemoveTableAlias_NoAlias() throws Exception {
+ String result = SqlColumnParser.removeTableAlias("uidlevel2");
+ assertEquals("uidlevel2", result);
+ }
+
+ @Test
+ void testRemoveTableAlias_EmptyString() throws Exception {
+ String result = SqlColumnParser.removeTableAlias("");
+ assertEquals("", result);
+ }
+
+ @Test
+ void testRemoveTableAlias_NullInput() throws Exception {
+ String result = SqlColumnParser.removeTableAlias(null);
+ assertNull(result);
+ }
+
+ @Test
+ void testRemoveTableAlias_ComplexColumnName() throws Exception {
+ String result = SqlColumnParser.removeTableAlias("schema.table.\"complex.column.name\"");
+ assertEquals("complex.column.name", result);
+ }
+
+ @Test
+ void testRemoveTableAlias_MultipleDots() throws Exception {
+ String result = SqlColumnParser.removeTableAlias("schema.table.column");
+ assertEquals("column", result);
+ }
+}
diff --git a/dhis-2/dhis-services/dhis-service-analytics/src/test/java/org/hisp/dhis/analytics/util/sql/SqlWhereClauseExtractorTest.java b/dhis-2/dhis-services/dhis-service-analytics/src/test/java/org/hisp/dhis/analytics/util/sql/SqlWhereClauseExtractorTest.java
new file mode 100644
index 000000000000..25ec5cc56d7b
--- /dev/null
+++ b/dhis-2/dhis-services/dhis-service-analytics/src/test/java/org/hisp/dhis/analytics/util/sql/SqlWhereClauseExtractorTest.java
@@ -0,0 +1,335 @@
+/*
+ * Copyright (c) 2004-2025, University of Oslo
+ * All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ * Redistributions of source code must retain the above copyright notice, this
+ * list of conditions and the following disclaimer.
+ *
+ * Redistributions in binary form must reproduce the above copyright notice,
+ * this list of conditions and the following disclaimer in the documentation
+ * and/or other materials provided with the distribution.
+ * Neither the name of the HISP project nor the names of its contributors may
+ * be used to endorse or promote products derived from this software without
+ * specific prior written permission.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
+ * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+ * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+ * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
+ * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
+ * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
+ * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
+ * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+ * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+package org.hisp.dhis.analytics.util.sql;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.util.List;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+class SqlWhereClauseExtractorTest {
+
+ @Test
+ @DisplayName("Extract single column from simple WHERE clause")
+ void testExtractWhereColumns_singleColumn() {
+ String sql = "SELECT * FROM table WHERE column1 = 'value'";
+ List columns = SqlWhereClauseExtractor.extractWhereColumns(sql);
+
+ assertEquals(1, columns.size());
+ assertTrue(columns.contains("column1"));
+ }
+
+ @Test
+ @DisplayName("Extract multiple columns from WHERE clause with AND")
+ void testExtractWhereColumns_multipleColumns() {
+ String sql = "SELECT * FROM table WHERE column1 = 'value' AND column2 = 10";
+ List columns = SqlWhereClauseExtractor.extractWhereColumns(sql);
+
+ assertEquals(2, columns.size());
+ assertTrue(columns.contains("column1"));
+ assertTrue(columns.contains("column2"));
+ }
+
+ @Test
+ @DisplayName("Extract column from WHERE clause with IN condition")
+ void testExtractWhereColumns_inCondition() {
+ String sql = "SELECT * FROM table WHERE column1 IN (1, 2, 3)";
+ List columns = SqlWhereClauseExtractor.extractWhereColumns(sql);
+
+ assertEquals(1, columns.size());
+ assertTrue(columns.contains("column1"));
+ }
+
+ @Test
+ @DisplayName("Extract columns from nested parentheses in WHERE clause")
+ void testExtractWhereColumns_nestedParentheses() {
+ String sql = "SELECT * FROM table WHERE (column1 = 'value' AND (column2 = 10))";
+ List columns = SqlWhereClauseExtractor.extractWhereColumns(sql);
+
+ assertEquals(2, columns.size());
+ assertTrue(columns.contains("column1"));
+ assertTrue(columns.contains("column2"));
+ }
+
+ @Test
+ @DisplayName("Return empty list for SQL without WHERE clause")
+ void testExtractWhereColumns_noWhereClause() {
+ String sql = "SELECT * FROM table";
+ List columns = SqlWhereClauseExtractor.extractWhereColumns(sql);
+
+ assertTrue(columns.isEmpty());
+ }
+
+ @Test
+ @DisplayName("Throw RuntimeException for null SQL input")
+ void testExtractWhereColumns_nullInput() {
+ assertThrows(
+ RuntimeException.class,
+ () -> {
+ SqlWhereClauseExtractor.extractWhereColumns(null);
+ });
+ }
+
+ @Test
+ @DisplayName("Throw RuntimeException for invalid SQL syntax")
+ void testExtractWhereColumns_invalidSql() {
+ String sql = "SELECT * FROM WHERE column1";
+ assertThrows(
+ RuntimeException.class,
+ () -> {
+ SqlWhereClauseExtractor.extractWhereColumns(sql);
+ });
+ }
+
+ @Test
+ @DisplayName("Extract column names without table aliases")
+ void testExtractWhereColumns_withTableAlias() {
+ String sql = "SELECT * FROM table t WHERE t.column1 = 'value'";
+ List columns = SqlWhereClauseExtractor.extractWhereColumns(sql);
+
+ assertEquals(1, columns.size());
+ assertTrue(columns.contains("column1"));
+ }
+
+ @Test
+ @DisplayName("Extract columns from complex WHERE clause with multiple conditions")
+ void testExtractWhereColumns_complexWhere() {
+ String sql =
+ "SELECT * FROM table WHERE column1 = 'value' AND "
+ + "(column2 IN (1,2) OR column3 != 'test')";
+ List columns = SqlWhereClauseExtractor.extractWhereColumns(sql);
+
+ assertEquals(3, columns.size());
+ assertTrue(columns.contains("column1"));
+ assertTrue(columns.contains("column2"));
+ assertTrue(columns.contains("column3"));
+ }
+
+ @Test
+ @DisplayName("Extract column from WHERE clause with LIKE operator")
+ void testExtractWhereColumns_likeOperator() {
+ String sql = "SELECT * FROM table WHERE column1 LIKE '%test%'";
+ List columns = SqlWhereClauseExtractor.extractWhereColumns(sql);
+
+ assertEquals(1, columns.size());
+ assertTrue(columns.contains("column1"));
+ }
+
+ @Test
+ @DisplayName("Extract column from WHERE clause with BETWEEN operator")
+ void testExtractWhereColumns_betweenOperator() {
+ String sql = "SELECT * FROM table WHERE column1 BETWEEN 1 AND 10";
+ List columns = SqlWhereClauseExtractor.extractWhereColumns(sql);
+
+ assertEquals(1, columns.size());
+ assertTrue(columns.contains("column1"));
+ }
+
+ @Test
+ @DisplayName("Extract column from WHERE clause with IS NULL condition")
+ void testExtractWhereColumns_isNullCondition() {
+ String sql = "SELECT * FROM table WHERE column1 IS NULL";
+ List columns = SqlWhereClauseExtractor.extractWhereColumns(sql);
+
+ assertEquals(1, columns.size());
+ assertTrue(columns.contains("column1"));
+ }
+
+ @Test
+ @DisplayName("Throw RuntimeException for empty string SQL")
+ void testExtractWhereColumns_emptyString() {
+ assertThrows(
+ RuntimeException.class,
+ () -> {
+ SqlWhereClauseExtractor.extractWhereColumns("");
+ });
+ }
+
+ @Test
+ @DisplayName("Extract columns from main query WHERE clause with subquery")
+ void testExtractWhereColumns_withSubquery() {
+ String sql =
+ "SELECT * FROM table WHERE column1 IN (SELECT id FROM other_table WHERE column2 = 'value')";
+ List columns = SqlWhereClauseExtractor.extractWhereColumns(sql);
+
+ assertEquals(1, columns.size());
+ assertTrue(columns.contains("column1"));
+ }
+
+ @Test
+ @DisplayName("Extract column from WHERE clause with function calls")
+ void testExtractWhereColumns_withFunctions() {
+ String sql = "SELECT * FROM table WHERE UPPER(column1) = 'TEST'";
+ List columns = SqlWhereClauseExtractor.extractWhereColumns(sql);
+
+ assertEquals(1, columns.size());
+ assertTrue(columns.contains("column1"));
+ }
+
+ @Test
+ @DisplayName("Extract column with special characters from WHERE clause")
+ void testExtractWhereColumns_specialCharacters() {
+ String sql = "SELECT * FROM table WHERE \"Special Column!\" = 'value'";
+ List columns = SqlWhereClauseExtractor.extractWhereColumns(sql);
+
+ assertEquals(1, columns.size());
+ assertTrue(columns.contains("Special Column!"));
+ }
+
+ @Test
+ @DisplayName("Preserve case sensitivity in column names")
+ void testExtractWhereColumns_caseSensitivity() {
+ String sql = "SELECT * FROM table WHERE COLUMN1 = 'value' AND column1 = 'test'";
+ List columns = SqlWhereClauseExtractor.extractWhereColumns(sql);
+
+ assertEquals(2, columns.size());
+ assertTrue(columns.contains("COLUMN1"));
+ assertTrue(columns.contains("column1"));
+ }
+
+ @Test
+ @DisplayName("Remove duplicate columns in WHERE clause")
+ void testExtractWhereColumns_duplicateColumns() {
+ String sql = "SELECT * FROM table WHERE column1 > 10 AND column1 < 20";
+ List columns = SqlWhereClauseExtractor.extractWhereColumns(sql);
+
+ assertEquals(1, columns.size());
+ assertTrue(columns.contains("column1"));
+ }
+
+ @Test
+ @DisplayName("Handle extremely long column names")
+ void testExtractWhereColumns_longColumnNames() {
+ String longColumnName = "a".repeat(128);
+ String sql = "SELECT * FROM table WHERE " + longColumnName + " = 'value'";
+ List columns = SqlWhereClauseExtractor.extractWhereColumns(sql);
+
+ assertEquals(1, columns.size());
+ assertTrue(columns.contains(longColumnName));
+ }
+
+ @Test
+ @DisplayName("Handle multiple complex conditions with mixed operators")
+ void testExtractWhereColumns_complexMixedOperators() {
+ String sql =
+ "SELECT * FROM table WHERE "
+ + "UPPER(column1) LIKE '%TEST%' AND "
+ + "(column2 BETWEEN 1 AND 10) OR "
+ + "column3 IS NOT NULL AND "
+ + "LOWER(column4) IN ('a', 'b', 'c')";
+ List columns = SqlWhereClauseExtractor.extractWhereColumns(sql);
+
+ assertEquals(4, columns.size());
+ assertTrue(columns.containsAll(List.of("column1", "column2", "column3", "column4")));
+ }
+
+ @Test
+ @DisplayName("Extract columns from multiple function calls")
+ void testExtractWhereColumns_multipleFunctions() {
+ String sql = "SELECT * FROM table WHERE UPPER(column1) = 'TEST' AND LOWER(column2) = 'test'";
+ List columns = SqlWhereClauseExtractor.extractWhereColumns(sql);
+
+ assertEquals(2, columns.size());
+ assertTrue(columns.contains("column1"));
+ assertTrue(columns.contains("column2"));
+ }
+
+ @Test
+ @DisplayName("Extract columns from nested function calls")
+ void testExtractWhereColumns_nestedFunctions() {
+ String sql = "SELECT * FROM table WHERE UPPER(TRIM(column1)) = 'TEST'";
+ List columns = SqlWhereClauseExtractor.extractWhereColumns(sql);
+
+ assertEquals(1, columns.size());
+ assertTrue(columns.contains("column1"));
+ }
+
+ @Test
+ @DisplayName("Extract columns from function with multiple parameters")
+ void testExtractWhereColumns_functionMultipleParams() {
+ String sql = "SELECT * FROM table WHERE CONCAT(column1, column2) = 'TEST'";
+ List columns = SqlWhereClauseExtractor.extractWhereColumns(sql);
+
+ assertEquals(2, columns.size());
+ assertTrue(columns.contains("column1"));
+ assertTrue(columns.contains("column2"));
+ }
+
+ @Test
+ @DisplayName("Extract columns from BETWEEN with column references")
+ void testExtractWhereColumns_betweenWithColumns() {
+ String sql = "SELECT * FROM table WHERE column1 BETWEEN column2 AND column3";
+ List columns = SqlWhereClauseExtractor.extractWhereColumns(sql);
+
+ assertEquals(3, columns.size());
+ assertTrue(columns.contains("column1"));
+ assertTrue(columns.contains("column2"));
+ assertTrue(columns.contains("column3"));
+ }
+
+ @Test
+ @DisplayName("Extract columns from multiple BETWEEN conditions")
+ void testExtractWhereColumns_multipleBetween() {
+ String sql =
+ "SELECT * FROM table WHERE column1 BETWEEN 1 AND 10 AND column2 BETWEEN 'A' AND 'Z'";
+ List columns = SqlWhereClauseExtractor.extractWhereColumns(sql);
+
+ assertEquals(2, columns.size());
+ assertTrue(columns.contains("column1"));
+ assertTrue(columns.contains("column2"));
+ }
+
+ @Test
+ @DisplayName("Extract columns from BETWEEN with functions")
+ void testExtractWhereColumns_betweenWithFunctions() {
+ String sql = "SELECT * FROM table WHERE column1 BETWEEN LOWER(column2) AND UPPER(column3)";
+ List columns = SqlWhereClauseExtractor.extractWhereColumns(sql);
+
+ assertEquals(3, columns.size());
+ assertTrue(columns.contains("column1"));
+ assertTrue(columns.contains("column2"));
+ assertTrue(columns.contains("column3"));
+ }
+
+ @Test
+ @DisplayName("Extract columns with various special characters")
+ void testExtractWhereColumns_variousSpecialCharacters() {
+ String sql =
+ "SELECT * FROM table WHERE "
+ + "\"Special!@#$%^&*()\" = 'value1' AND "
+ + "\"Column With Spaces\" = 'value2' AND "
+ + "\"Mixed_Case-Column\" = 'value3'";
+ List columns = SqlWhereClauseExtractor.extractWhereColumns(sql);
+
+ assertEquals(3, columns.size());
+ assertTrue(columns.contains("Special!@#$%^&*()"));
+ assertTrue(columns.contains("Column With Spaces"));
+ assertTrue(columns.contains("Mixed_Case-Column"));
+ }
+}
diff --git a/dhis-2/dhis-services/dhis-service-setting/src/test/java/org/hisp/dhis/setting/SystemSettingsTest.java b/dhis-2/dhis-services/dhis-service-setting/src/test/java/org/hisp/dhis/setting/SystemSettingsTest.java
index e630fbe07a28..87447c405042 100644
--- a/dhis-2/dhis-services/dhis-service-setting/src/test/java/org/hisp/dhis/setting/SystemSettingsTest.java
+++ b/dhis-2/dhis-services/dhis-service-setting/src/test/java/org/hisp/dhis/setting/SystemSettingsTest.java
@@ -93,10 +93,11 @@ void testIsTranslatable() {
@Test
void testKeysWithDefaults() {
Set keys = SystemSettings.keysWithDefaults();
- assertEquals(139, keys.size());
+ assertEquals(140, keys.size());
// just check some at random
assertTrue(keys.contains("syncSkipSyncForDataChangedBefore"));
assertTrue(keys.contains("keyTrackerDashboardLayout"));
+ assertTrue(keys.contains("experimentalAnalyticsSqlEngineEnabled"));
}
@Test
diff --git a/dhis-2/pom.xml b/dhis-2/pom.xml
index c5718fd77333..98a8772478d1 100644
--- a/dhis-2/pom.xml
+++ b/dhis-2/pom.xml
@@ -1613,7 +1613,11 @@
debezium-connector-postgres
${version.debezium}