diff --git a/dhis-2/dhis-services/dhis-service-analytics/src/main/java/org/hisp/dhis/analytics/common/CteContext.java b/dhis-2/dhis-services/dhis-service-analytics/src/main/java/org/hisp/dhis/analytics/common/CteContext.java index 72de799e1610..1df6cebbd0e3 100644 --- a/dhis-2/dhis-services/dhis-service-analytics/src/main/java/org/hisp/dhis/analytics/common/CteContext.java +++ b/dhis-2/dhis-services/dhis-service-analytics/src/main/java/org/hisp/dhis/analytics/common/CteContext.java @@ -105,29 +105,22 @@ public void addCteFilter(QueryItem item, String ctedefinition) { } } - public String getCteDefinition() { - if (cteDefinitions.isEmpty()) { - return ""; - } - StringBuilder sb = new StringBuilder("with "); - boolean first = true; - for (Map.Entry entry : cteDefinitions.entrySet()) { - if (!first) { - sb.append(", "); - } - CteDefinition cteDef = entry.getValue(); - sb.append(cteDef.asCteName(entry.getKey())) - .append(" AS (") - .append(entry.getValue().getCteDefinition()) - .append(")"); - first = false; - } - return sb.toString(); + /** + * Returns the CTE definitions as a map. The key is the CTE name and the value is the CTE + * definition (the SQL query). + * + * @return the CTE definitions + */ + public Map getCteDefinitions() { + return cteDefinitions.entrySet().stream() + .collect( + LinkedHashMap::new, + (map, entry) -> map.put(entry.getKey(), entry.getValue().getCteDefinition()), + Map::putAll); } - // Rename to item uid - public Set getCteNames() { + public Set getCteKeys() { return cteDefinitions.keySet(); } diff --git a/dhis-2/dhis-services/dhis-service-analytics/src/main/java/org/hisp/dhis/analytics/event/data/JdbcEnrollmentAnalyticsManager.java b/dhis-2/dhis-services/dhis-service-analytics/src/main/java/org/hisp/dhis/analytics/event/data/JdbcEnrollmentAnalyticsManager.java index 2f95581513e6..199726b93c00 100644 --- a/dhis-2/dhis-services/dhis-service-analytics/src/main/java/org/hisp/dhis/analytics/event/data/JdbcEnrollmentAnalyticsManager.java +++ b/dhis-2/dhis-services/dhis-service-analytics/src/main/java/org/hisp/dhis/analytics/event/data/JdbcEnrollmentAnalyticsManager.java @@ -68,6 +68,8 @@ import org.hisp.dhis.analytics.event.EventQueryParams; import org.hisp.dhis.analytics.table.AbstractJdbcTableManager; import org.hisp.dhis.analytics.table.EnrollmentAnalyticsColumnName; +import org.hisp.dhis.analytics.util.sql.Condition; +import org.hisp.dhis.analytics.util.sql.SelectBuilder; import org.hisp.dhis.category.CategoryOption; import org.hisp.dhis.common.DimensionItemType; import org.hisp.dhis.common.DimensionType; @@ -1055,65 +1057,41 @@ private void buildProgramStageCte( } } - private void appendCteJoins(StringBuilder sql, CteContext cteContext) { - for (String itemUid : cteContext.getCteNames()) { + private void addCteJoins(SelectBuilder builder, CteContext cteContext) { + for (String itemUid : cteContext.getCteKeys()) { CteDefinition cteDef = cteContext.getDefinitionByItemUid(itemUid); // Handle Program Stage CTE (potentially with multiple offsets) if (cteDef.isProgramStage()) { for (Integer offset : cteDef.getOffsets()) { String alias = cteDef.getAlias(offset); - // Using a text block with .formatted() for clarity: - String join = - """ - LEFT JOIN %s %s - ON %s.enrollment = ax.enrollment - AND %s.rn = %d - """ - .formatted( - cteDef.asCteName(itemUid), // e.g. ps_ABC123_xyz - alias, // random alias - alias, // alias for table - alias, // alias repeated - offset + 1 // offset index - ); - sql.append(join); + builder.leftJoin( + itemUid, + alias, + tableAlias -> + tableAlias + + ".enrollment = ax.enrollment AND " + + tableAlias + + ".rn = " + + (offset + 1)); } } // Handle 'Exists' type CTE if (cteDef.isExists()) { - String join = - String.format( - """ - LEFT JOIN %s ee ON ee.enrollment = ax.enrollment - """, - cteDef.asCteName(itemUid)); - sql.append(join); + builder.leftJoin(itemUid, "ee", tableAlias -> tableAlias + ".enrollment = ax.enrollment"); } // Handle Program Indicator CTE if (cteDef.isProgramIndicator()) { String alias = cteDef.getAlias(); - String join = - """ - LEFT JOIN %s %s - ON %s.enrollment = ax.enrollment - """ - .formatted(cteDef.asCteName(itemUid), alias, alias); - sql.append(join); + builder.leftJoin(itemUid, alias, tableAlias -> tableAlias + ".enrollment = ax.enrollment"); } // Handle Filter CTE if (cteDef.isFilter()) { String alias = cteDef.getAlias(); - String join = - """ - LEFT JOIN %s %s - ON %s.enrollment = ax.enrollment - """ - .formatted(cteDef.asCteName(itemUid), alias, alias); - sql.append(join); + builder.leftJoin(itemUid, alias, tableAlias -> tableAlias + ".enrollment = ax.enrollment"); } } } @@ -1204,99 +1182,101 @@ private String buildEnrollmentQueryWithCte(EventQueryParams params) { generateFilterCTEs(params, cteContext); // 3. Build up the final SQL using dedicated sub-steps - StringBuilder sql = new StringBuilder(); + SelectBuilder sb = new SelectBuilder(); // 3.1: Append the WITH clause if needed - appendCteClause(sql, cteContext); + addCteClause(sb, cteContext); // 3.2: Append the SELECT clause, including columns from the CTE context - appendSelectClause(sql, params, cteContext); + addSelectClause(sb, params, cteContext); // 3.3: Append the FROM clause (the main enrollment analytics table) - appendFromClause(sql, params); + addFromClause(sb, params); // 3.4: Append LEFT JOINs for each relevant CTE definition - appendCteJoins(sql, cteContext); + addCteJoins(sb, cteContext); // 3.5: Collect and append WHERE conditions (including filters from CTE) - appendWhereClause(sql, params, cteContext); + addWhereClause(sb, params, cteContext); // 3.6: Append ORDER BY and paging - appendSortingAndPaging(sql, params); + addSortingAndPaging(sb, params); - return sql.toString(); + return sb.build(); } /** * Appends the WITH clause using the CTE definitions from cteContext. If there are no CTE * definitions, nothing is appended. */ - private void appendCteClause(StringBuilder sql, CteContext cteContext) { - String cteDefinitions = cteContext.getCteDefinition(); - if (!cteDefinitions.isEmpty()) { - sql.append(cteDefinitions).append("\n"); - } + private void addCteClause(SelectBuilder sb, CteContext cteContext) { + cteContext + .getCteDefinitions() + .forEach(sb::withCTE); + } + + private boolean columnIsInFormula(String col) { + return col.contains("(") && col.contains(")"); } /** * Appends the SELECT clause, including both the standard enrollment columns (or aggregated * columns) and columns derived from the CTE definitions. */ - private void appendSelectClause( - StringBuilder sql, EventQueryParams params, CteContext cteContext) { - // Get the standard columns, and prepend the alias "ax." to each column name - // (except for columns that are part of a function, e.g. "count(abc)"). - List aliasedSelectCols = - getStandardColumns().stream() - .map(c -> (!c.contains("(") || !c.contains(")")) ? "ax." + c : c) - .toList(); + private void addSelectClause(SelectBuilder sb, EventQueryParams params, CteContext cteContext) { - List selectCols = - ListUtils.distinctUnion( - params.isAggregatedEnrollments() ? List.of("enrollment") : aliasedSelectCols, - getSelectColumnsWithCTE(params, cteContext)); + // Append standard columns or aggregated columns + if (params.isAggregatedEnrollments()) { + sb.addColumn("enrollment", "ax"); + } else { + getStandardColumns() + .forEach( + column -> { + if (columnIsInFormula(column)) { + sb.addColumn(column); + } else { + sb.addColumn(column, "ax"); + } + }); + } - // Join the list of select columns with commas and append - sql.append("select ").append(String.join(",\n", selectCols)).append("\n"); + // Append columns from CTE definitions + getSelectColumnsWithCTE(params, cteContext).forEach(sb::addColumn); } /** Appends the FROM clause, i.e. the main table name and alias. */ - private void appendFromClause(StringBuilder sql, EventQueryParams params) { - sql.append("from ").append(params.getTableName()).append(" AS ax"); + private void addFromClause(SelectBuilder sb, EventQueryParams params) { + sb.from(params.getTableName(), "ax"); } /** * Collects the WHERE conditions from both the base enrollment table and the CTE-based filters, * then appends them to the SQL. */ - private void appendWhereClause( - StringBuilder sql, EventQueryParams params, CteContext cteContext) { - List conditions = new ArrayList<>(); - - String baseWhereClause = getWhereClause(params).trim(); - String cteFilters = addCteFiltersToWhereClause(params, cteContext).trim(); - - if (!baseWhereClause.isEmpty()) { - // Remove leading "WHERE" if present - conditions.add(baseWhereClause.replaceFirst("(?i)^WHERE\\s+", "")); - } - if (!cteFilters.isEmpty()) { - conditions.add(cteFilters.replaceFirst("(?i)^AND\\s+", "")); - } - - if (!conditions.isEmpty()) { - sql.append(" WHERE ").append(String.join(" AND ", conditions)); - } + private void addWhereClause(SelectBuilder sb, EventQueryParams params, CteContext cteContext) { + Condition baseConditions = Condition.raw(getWhereClause(params)); + Condition cteConditions = Condition.raw(addCteFiltersToWhereClause(params, cteContext)); + sb.where(Condition.and(baseConditions, cteConditions)); } - /** Appends the ORDER BY clause if sorting is specified, plus LIMIT/OFFSET for paging. */ - private void appendSortingAndPaging(StringBuilder sql, EventQueryParams params) { - // Add ORDER BY if needed + private void addSortingAndPaging(SelectBuilder builder, EventQueryParams params) { if (params.isSorting()) { - sql.append(" ").append(getSortClause(params)); + // Assuming getSortFields returns List + builder.orderBy(getSortClause(params)); + } + + // Paging with max limit of 5000 + if (params.isPaging()) { + if (params.isTotalPages()) { + builder.limitWithMax(params.getPageSizeWithDefault(), 5000).offset(params.getOffset()); + } else { + builder + .limitWithMaxPlusOne(params.getPageSizeWithDefault(), 5000) + .offset(params.getOffset()); + } + } else { + builder.limitPlusOne(5000); } - // Add paging (LIMIT/OFFSET) - sql.append(" ").append(getPagingClause(params, 5000)); } protected String getSortClause(EventQueryParams params) { diff --git a/dhis-2/dhis-services/dhis-service-analytics/src/main/java/org/hisp/dhis/analytics/util/sql/Condition.java b/dhis-2/dhis-services/dhis-service-analytics/src/main/java/org/hisp/dhis/analytics/util/sql/Condition.java new file mode 100644 index 000000000000..9293d0644f21 --- /dev/null +++ b/dhis-2/dhis-services/dhis-service-analytics/src/main/java/org/hisp/dhis/analytics/util/sql/Condition.java @@ -0,0 +1,190 @@ +/* + * 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.Arrays; +import java.util.Collection; +import java.util.List; +import java.util.stream.Collectors; + +/** + * Represents SQL conditions that can be used in WHERE and HAVING clauses. This interface provides a + * type-safe way to build SQL conditions using composition and various condition types (AND, OR, + * NOT). + */ +public sealed interface Condition + permits Condition.And, + Condition.Not, + Condition.Or, + Condition.Raw, + NotCondition, + SimpleCondition { + /** + * Converts the condition to its SQL string representation. + * + * @return the SQL string representation of the condition + */ + String toSql(); + + /** + * Represents a raw SQL condition string. Automatically removes leading "WHERE" or "AND" keywords. + * + *

Examples: + * + *

{@code
+   * // These will produce the same SQL:
+   * new Raw("active = true")            -> "active = true"
+   * new Raw("WHERE active = true")      -> "active = true"
+   * new Raw("AND active = true")        -> "active = true"
+   *
+   * // Complex conditions are preserved:
+   * new Raw("WHERE age >= 18 AND status IN ('ACTIVE', 'PENDING')")
+   *     -> "age >= 18 AND status IN ('ACTIVE', 'PENDING')"
+   *
+   * // Case insensitive keyword removal:
+   * new Raw("where active = true")      -> "active = true"
+   * new Raw("WHERE active = true")      -> "active = true"
+   * new Raw("And active = true")        -> "active = true"
+   * }
+ */ + record Raw(String sql) implements Condition { + @Override + public String toSql() { + if (sql == null || sql.trim().isEmpty()) { + return ""; + } + + // Remove only the first occurrence of WHERE or AND + String cleaned = sql.trim(); + if (cleaned.toLowerCase().matches("^(where|and)\\b.*")) { + cleaned = cleaned.replaceFirst("(?i)^(where|and)\\s+", ""); + } + + return cleaned.trim(); + } + } + + /** + * Represents multiple conditions combined with AND operator. Empty conditions are filtered out + * from the final SQL. + */ + record And(List conditions) implements Condition { + @Override + public String toSql() { + return conditions.stream() + .map(Condition::toSql) + .filter(s -> !s.isEmpty()) + .collect(Collectors.joining(" and ")); + } + } + + /** + * Represents multiple conditions combined with OR operator. Empty conditions are filtered out and + * each condition is wrapped in parentheses. + */ + record Or(List conditions) implements Condition { + @Override + public String toSql() { + return conditions.stream() + .map(Condition::toSql) + .filter(s -> !s.isEmpty()) + .map(sql -> "(" + sql + ")") + .collect(Collectors.joining(" or ")); + } + } + + /** Represents a negated condition. If the inner condition is empty, returns an empty string. */ + record Not(Condition condition) implements Condition { + @Override + public String toSql() { + String sql = condition.toSql(); + return sql.isEmpty() ? "" : "not (" + sql + ")"; + } + } + + /** + * Creates a condition from a raw SQL string. + * + * @param sql the SQL condition string + * @return a new Raw condition + */ + static Condition raw(String sql) { + return new Raw(sql); + } + + /** + * Combines multiple conditions with AND operator. + * + * @param conditions the conditions to combine + * @return a new And condition containing all provided conditions + */ + static Condition and(Condition... conditions) { + return new And(Arrays.asList(conditions)); + } + + /** + * Combines a collection of conditions with AND operator. + * + * @param conditions the collection of conditions to combine + * @return a new And condition containing all provided conditions + */ + static Condition and(Collection conditions) { + return new And(new ArrayList<>(conditions)); + } + + /** + * Combines multiple conditions with OR operator. + * + * @param conditions the conditions to combine + * @return a new Or condition containing all provided conditions + */ + static Condition or(Condition... conditions) { + return new Or(Arrays.asList(conditions)); + } + + /** + * Combines a collection of conditions with OR operator. + * + * @param conditions the collection of conditions to combine + * @return a new Or condition containing all provided conditions + */ + static Condition or(Collection conditions) { + return new Or(new ArrayList<>(conditions)); + } + + /** + * Creates a negated condition. + * + * @param condition the condition to negate + * @return a new Not condition wrapping the provided condition + */ + static Condition not(Condition condition) { + return new Not(condition); + } +} diff --git a/dhis-2/dhis-services/dhis-service-analytics/src/main/java/org/hisp/dhis/analytics/util/sql/JoinCondition.java b/dhis-2/dhis-services/dhis-service-analytics/src/main/java/org/hisp/dhis/analytics/util/sql/JoinCondition.java new file mode 100644 index 000000000000..b72d1e4c4bda --- /dev/null +++ b/dhis-2/dhis-services/dhis-service-analytics/src/main/java/org/hisp/dhis/analytics/util/sql/JoinCondition.java @@ -0,0 +1,68 @@ +/* + * 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; +/** + * Functional interface for building SQL JOIN conditions. + * The interface takes a table alias and returns the corresponding JOIN condition. + * + *

Examples: + *

{@code
+ * // Simple join on id
+ * JoinCondition idJoin = alias -> alias + ".user_id = users.id";
+ * // Usage: "LEFT JOIN orders o ON o.user_id = users.id"
+ *
+ * // Join with multiple conditions
+ * JoinCondition activeUserJoin = alias ->
+ *     alias + ".user_id = users.id AND " +
+ *     alias + ".status = 'ACTIVE'";
+ * // Usage: "LEFT JOIN orders o ON o.user_id = users.id AND o.status = 'ACTIVE'"
+ *
+ * // Join with date range
+ * JoinCondition dateRangeJoin = alias ->
+ *     alias + ".start_date <= CURRENT_DATE AND " +
+ *     alias + ".end_date >= CURRENT_DATE";
+ * // Usage: "LEFT JOIN periods p ON p.start_date <= CURRENT_DATE AND p.end_date >= CURRENT_DATE"
+ * }
+ * + *

Typical usage in SelectBuilder: + *

{@code
+ * SelectBuilder builder = new SelectBuilder()
+ *     .from("users", "u")
+ *     .leftJoin("orders", "o", alias -> alias + ".user_id = u.id");
+ * }
+ */ +@FunctionalInterface +public interface JoinCondition { + /** + * Builds a JOIN condition string using the provided table alias. + * + * @param alias the alias of the table being joined + * @return the SQL JOIN condition string + */ + String build(String alias); +} diff --git a/dhis-2/dhis-services/dhis-service-analytics/src/main/java/org/hisp/dhis/analytics/util/sql/NotCondition.java b/dhis-2/dhis-services/dhis-service-analytics/src/main/java/org/hisp/dhis/analytics/util/sql/NotCondition.java new file mode 100644 index 000000000000..48b7502df3bf --- /dev/null +++ b/dhis-2/dhis-services/dhis-service-analytics/src/main/java/org/hisp/dhis/analytics/util/sql/NotCondition.java @@ -0,0 +1,56 @@ +/* + * 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 negated SQL condition. + * Wraps another condition with a NOT operator and parentheses. + * + *

Examples: + *

{@code
+ * // Simple negation
+ * new NotCondition(new SimpleCondition("active = true"))
+ *     -> "NOT (active = true)"
+ *
+ * // Negating complex conditions
+ * new NotCondition(Condition.and(
+ *     new SimpleCondition("status = 'ACTIVE'"),
+ *     new SimpleCondition("age >= 18")
+ * ))
+ *     -> "NOT (status = 'ACTIVE' and age >= 18)"
+ *
+ * // Can be created using the factory method
+ * Condition.not(new SimpleCondition("in_stock = true"))
+ *     -> "NOT (in_stock = true)"
+ * }
+ */ +public record NotCondition(Condition condition) implements Condition { + @Override + public String toSql() { + return "NOT (" + condition.toSql() + ")"; + } +} diff --git a/dhis-2/dhis-services/dhis-service-analytics/src/main/java/org/hisp/dhis/analytics/util/sql/SelectBuilder.java b/dhis-2/dhis-services/dhis-service-analytics/src/main/java/org/hisp/dhis/analytics/util/sql/SelectBuilder.java new file mode 100644 index 000000000000..018f16061ccd --- /dev/null +++ b/dhis-2/dhis-services/dhis-service-analytics/src/main/java/org/hisp/dhis/analytics/util/sql/SelectBuilder.java @@ -0,0 +1,684 @@ +/* + * 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.Arrays; +import java.util.List; +import java.util.stream.Collectors; + +/** + * A fluent builder for creating SQL SELECT queries. + * Supports common SQL features including CTEs, JOINs, WHERE conditions, GROUP BY, + * HAVING, ORDER BY, and pagination. + * + *

Example usage: + *

{@code
+ * String sql = new SelectBuilder()
+ *     .withCTE("active_users", "SELECT id FROM users WHERE active = true")
+ *     .addColumn("u.name")
+ *     .addColumn("COUNT(o.id)", "order_count")
+ *     .from("users", "u")
+ *     .leftJoin("orders", "o", alias -> alias + ".user_id = u.id")
+ *     .where(Condition.raw("u.id IN (SELECT id FROM active_users)"))
+ *     .groupBy("u.name")
+ *     .having(Condition.raw("COUNT(o.id) > 0"))
+ *     .orderBy("order_count", "DESC", "NULLS LAST")
+ *     .limit(10)
+ *     .build();
+ * }
+ */ +public class SelectBuilder { + /** Maximum limit for pagination to prevent excessive resource usage */ + private static final int DEFAULT_MAX_LIMIT = 5000; + + private final List ctes = new ArrayList<>(); + private final List columns = new ArrayList<>(); + private String fromTable; + private String fromAlias; + private final List joins = new ArrayList<>(); + private Condition whereCondition; + private final List groupByClauses = new ArrayList<>(); + private final List havingConditions = new ArrayList<>(); + private final List 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; + } + + /** + * 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; + } + + /** + * Sets the FROM clause table without an alias. + * + * @param table the table name + * @return this builder instance + */ + public SelectBuilder from(String table) { + this.fromTable = 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 = 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 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()); + } + + /** + * 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}; + } +} 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..6fdcb8ca0dc6 --- /dev/null +++ b/dhis-2/dhis-services/dhis-service-analytics/src/main/java/org/hisp/dhis/analytics/util/sql/SimpleCondition.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; + +/** + * 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/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..d3befc5cf652 --- /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/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..4a92acff607c --- /dev/null +++ b/dhis-2/dhis-services/dhis-service-analytics/src/test/java/org/hisp/dhis/analytics/util/sql/SelectBuilderTest.java @@ -0,0 +1,825 @@ +/* + * 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); + } + } +}