From 7bc2ebddb0486fe9d06df6933040d0b48a9a0540 Mon Sep 17 00:00:00 2001 From: ez-plugins Date: Sat, 18 Apr 2026 14:22:05 +0200 Subject: [PATCH 1/4] feat: add subquery support to core model and SQL rendering - Operator: add EXISTS_SUBQUERY and NOT_EXISTS_SUBQUERY enum values - Condition: guard matches() against Query-valued values; extract matchesEq() helper to keep cyclomatic complexity within the 10-method limit - Query: add fromSubquery, fromAlias, joins (List), and selectSubqueries (List) fields with accessors - JoinClause: new immutable holder for plain-table and derived-table JOINs; inner enum Type {INNER, LEFT, RIGHT, CROSS} - ScalarSelectItem: new immutable holder for (SELECT ...) AS alias items in the SELECT clause - AbstractSqlDialect: rewrite render() and renderDelete() to support all new query fields; add appendFromClause, appendJoinClauses, appendSelectColumns (with scalar-subquery param collection), appendSubqueryCondition and five private helpers; deterministic param ordering: scalar SELECT -> FROM -> JOIN -> WHERE --- .../javaquerybuilder/query/JoinClause.java | 121 +++++++++++ .../javaquerybuilder/query/Query.java | 84 ++++++++ .../query/ScalarSelectItem.java | 48 +++++ .../query/condition/Condition.java | 26 ++- .../query/condition/Operator.java | 12 +- .../query/sql/AbstractSqlDialect.java | 190 +++++++++++++++--- 6 files changed, 440 insertions(+), 41 deletions(-) create mode 100644 src/main/java/com/github/ezframework/javaquerybuilder/query/JoinClause.java create mode 100644 src/main/java/com/github/ezframework/javaquerybuilder/query/ScalarSelectItem.java diff --git a/src/main/java/com/github/ezframework/javaquerybuilder/query/JoinClause.java b/src/main/java/com/github/ezframework/javaquerybuilder/query/JoinClause.java new file mode 100644 index 0000000..c321e70 --- /dev/null +++ b/src/main/java/com/github/ezframework/javaquerybuilder/query/JoinClause.java @@ -0,0 +1,121 @@ +package com.github.ezframework.javaquerybuilder.query; + +/** + * Represents a single JOIN entry in a SELECT query, which may target either a + * plain table name or a derived-table subquery. + * + *

Exactly one of {@link #getTable()} or {@link #getSubquery()} will be non-null. + * When {@link #getSubquery()} is non-null, {@link #getAlias()} provides the alias + * used in the rendered SQL (e.g. {@code JOIN (SELECT ...) AS alias ON ...}). + * + * @author EzFramework + * @version 1.0.0 + */ +public final class JoinClause { + + /** + * The join type. + */ + public enum Type { + /** {@code INNER JOIN}. */ + INNER, + /** {@code LEFT JOIN}. */ + LEFT, + /** {@code RIGHT JOIN}. */ + RIGHT, + /** {@code CROSS JOIN}. */ + CROSS + } + + /** The join type. */ + private final Type type; + + /** The table name for a plain table join; {@code null} for subquery joins. */ + private final String table; + + /** The subquery for a derived-table join; {@code null} for plain table joins. */ + private final Query subquery; + + /** The alias used when the join target is a subquery. */ + private final String alias; + + /** Raw SQL fragment for the ON condition, e.g. {@code "t.id = other.id"}. */ + private final String onCondition; + + /** + * Creates a plain-table JOIN clause. + * + * @param type the join type + * @param table the target table name + * @param onCondition the raw SQL ON condition + */ + public JoinClause(Type type, String table, String onCondition) { + this.type = type; + this.table = table; + this.subquery = null; + this.alias = null; + this.onCondition = onCondition; + } + + /** + * Creates a subquery (derived-table) JOIN clause. + * + * @param type the join type + * @param subquery the subquery to join against + * @param alias the alias for the derived table + * @param onCondition the raw SQL ON condition + */ + public JoinClause(Type type, Query subquery, String alias, String onCondition) { + this.type = type; + this.table = null; + this.subquery = subquery; + this.alias = alias; + this.onCondition = onCondition; + } + + /** + * Returns the join type. + * + * @return the join type + */ + public Type getType() { + return type; + } + + /** + * Returns the target table name for a plain table join, or {@code null} for a subquery join. + * + * @return the table name, or {@code null} + */ + public String getTable() { + return table; + } + + /** + * Returns the subquery for a derived-table join, or {@code null} for a plain table join. + * + * @return the subquery, or {@code null} + */ + public Query getSubquery() { + return subquery; + } + + /** + * Returns the alias used for the derived table in a subquery join, or {@code null} + * for a plain table join. + * + * @return the alias, or {@code null} + */ + public String getAlias() { + return alias; + } + + /** + * Returns the raw SQL ON condition fragment. + * + * @return the ON condition SQL fragment + */ + public String getOnCondition() { + return onCondition; + } +} diff --git a/src/main/java/com/github/ezframework/javaquerybuilder/query/Query.java b/src/main/java/com/github/ezframework/javaquerybuilder/query/Query.java index 770fef1..4e8dafd 100644 --- a/src/main/java/com/github/ezframework/javaquerybuilder/query/Query.java +++ b/src/main/java/com/github/ezframework/javaquerybuilder/query/Query.java @@ -43,6 +43,18 @@ public class Query { /** The HAVING clause (raw SQL fragment). */ private String havingRaw = null; + /** The FROM subquery when using a derived-table source; {@code null} for a plain table source. */ + private Query fromSubquery = null; + + /** The alias used for the FROM derived-table; only meaningful when {@code fromSubquery != null}. */ + private String fromAlias = null; + + /** The list of JOIN clauses (plain table or subquery). */ + private List joins = new ArrayList<>(); + + /** The list of scalar subquery items appended to the SELECT clause. */ + private List selectSubqueries = new ArrayList<>(); + /** * Gets the source table for the query. * @@ -222,4 +234,76 @@ public String getHavingRaw() { public void setHavingRaw(String havingRaw) { this.havingRaw = havingRaw; } + + /** + * Returns the FROM subquery when this query uses a derived-table source. + * + * @return the FROM subquery, or {@code null} for a plain table source + */ + public Query getFromSubquery() { + return fromSubquery; + } + + /** + * Sets the FROM subquery for a derived-table source. + * + * @param fromSubquery the subquery to use as the FROM source + */ + public void setFromSubquery(Query fromSubquery) { + this.fromSubquery = fromSubquery; + } + + /** + * Returns the alias for the FROM derived-table. + * + * @return the alias, or {@code null} when not using a subquery source + */ + public String getFromAlias() { + return fromAlias; + } + + /** + * Sets the alias for the FROM derived-table. + * + * @param fromAlias the alias to use + */ + public void setFromAlias(String fromAlias) { + this.fromAlias = fromAlias; + } + + /** + * Returns the list of JOIN clauses for this query. + * + * @return the list of JOIN clauses; never {@code null} + */ + public List getJoins() { + return joins; + } + + /** + * Sets the list of JOIN clauses. + * + * @param joins the list of JOIN clauses + */ + public void setJoins(List joins) { + this.joins = joins; + } + + /** + * Returns the list of scalar subquery items in the SELECT clause. + * + * @return the list of scalar select items; never {@code null} + */ + public List getSelectSubqueries() { + return selectSubqueries; + } + + /** + * Sets the list of scalar subquery items for the SELECT clause. + * + * @param selectSubqueries the list of scalar select items + */ + public void setSelectSubqueries(List selectSubqueries) { + this.selectSubqueries = selectSubqueries; + } } diff --git a/src/main/java/com/github/ezframework/javaquerybuilder/query/ScalarSelectItem.java b/src/main/java/com/github/ezframework/javaquerybuilder/query/ScalarSelectItem.java new file mode 100644 index 0000000..8cdcc9c --- /dev/null +++ b/src/main/java/com/github/ezframework/javaquerybuilder/query/ScalarSelectItem.java @@ -0,0 +1,48 @@ +package com.github.ezframework.javaquerybuilder.query; + +/** + * Represents a scalar subquery item in a SELECT clause. + * + *

When included in a query's select list, the subquery is rendered as + * {@code (SELECT ...) AS alias} in the SELECT clause. + * + * @author EzFramework + * @version 1.0.0 + */ +public final class ScalarSelectItem { + + /** The subquery to embed in the SELECT clause. */ + private final Query subquery; + + /** The alias for the scalar subquery column. */ + private final String alias; + + /** + * Creates a scalar select item. + * + * @param subquery the subquery to embed + * @param alias the column alias + */ + public ScalarSelectItem(Query subquery, String alias) { + this.subquery = subquery; + this.alias = alias; + } + + /** + * Returns the subquery to embed in the SELECT clause. + * + * @return the subquery + */ + public Query getSubquery() { + return subquery; + } + + /** + * Returns the alias for the scalar subquery column. + * + * @return the alias + */ + public String getAlias() { + return alias; + } +} diff --git a/src/main/java/com/github/ezframework/javaquerybuilder/query/condition/Condition.java b/src/main/java/com/github/ezframework/javaquerybuilder/query/condition/Condition.java index 042faf4..3916a59 100644 --- a/src/main/java/com/github/ezframework/javaquerybuilder/query/condition/Condition.java +++ b/src/main/java/com/github/ezframework/javaquerybuilder/query/condition/Condition.java @@ -2,6 +2,8 @@ import java.util.Map; +import com.github.ezframework.javaquerybuilder.query.Query; + /** * A single field condition used by {@link com.github.ezframework.javaquerybuilder.query.Query}. * @@ -59,24 +61,30 @@ public boolean matches(Map map, String key) { case EXISTS: return exists; case EQ: - if (v == null) { - return value == null; - } - return v.equals(value); + return matchesEq(v); case NEQ: - if (v == null) { - return value != null; - } - return !v.equals(value); + return !matchesEq(v); case LIKE: return matchesLike(v); case IN: - return matchesIn(v); + return !(value instanceof Query) && matchesIn(v); + case NOT_IN: + return !(value instanceof Query) && !matchesIn(v); default: return false; } } + private boolean matchesEq(Object v) { + if (value instanceof Query) { + return false; + } + if (v == null) { + return value == null; + } + return v.equals(value); + } + private boolean matchesLike(Object v) { if (v == null || value == null) { return false; diff --git a/src/main/java/com/github/ezframework/javaquerybuilder/query/condition/Operator.java b/src/main/java/com/github/ezframework/javaquerybuilder/query/condition/Operator.java index 78cbe3d..9300af0 100644 --- a/src/main/java/com/github/ezframework/javaquerybuilder/query/condition/Operator.java +++ b/src/main/java/com/github/ezframework/javaquerybuilder/query/condition/Operator.java @@ -34,5 +34,15 @@ public enum Operator { /** Negated collection membership ({@code NOT IN (...)}). */ NOT_IN, /** Inclusive range check ({@code BETWEEN ? AND ?}). */ - BETWEEN + BETWEEN, + /** + * True SQL {@code EXISTS (SELECT ...)} — value must be a + * {@link com.github.ezframework.javaquerybuilder.query.Query}. + */ + EXISTS_SUBQUERY, + /** + * True SQL {@code NOT EXISTS (SELECT ...)} — value must be a + * {@link com.github.ezframework.javaquerybuilder.query.Query}. + */ + NOT_EXISTS_SUBQUERY } diff --git a/src/main/java/com/github/ezframework/javaquerybuilder/query/sql/AbstractSqlDialect.java b/src/main/java/com/github/ezframework/javaquerybuilder/query/sql/AbstractSqlDialect.java index a1d0f8b..6de5d45 100644 --- a/src/main/java/com/github/ezframework/javaquerybuilder/query/sql/AbstractSqlDialect.java +++ b/src/main/java/com/github/ezframework/javaquerybuilder/query/sql/AbstractSqlDialect.java @@ -6,7 +6,9 @@ import java.util.Map; import java.util.stream.Collectors; +import com.github.ezframework.javaquerybuilder.query.JoinClause; import com.github.ezframework.javaquerybuilder.query.Query; +import com.github.ezframework.javaquerybuilder.query.ScalarSelectItem; import com.github.ezframework.javaquerybuilder.query.condition.ConditionEntry; import com.github.ezframework.javaquerybuilder.query.condition.Operator; @@ -19,6 +21,14 @@ * and {@link #supportsDeleteLimit()} to enable dialect-specific DELETE * {@code LIMIT} behaviour. * + *

Subquery support — parameter ordering contract: + *

    + *
  1. SELECT-list scalar subquery parameters (left to right)
  2. + *
  3. FROM subquery parameters
  4. + *
  5. JOIN subquery parameters (left to right)
  6. + *
  7. WHERE condition subquery parameters (top to bottom)
  8. + *
+ * * @author EzFramework * @version 1.0.0 */ @@ -74,12 +84,12 @@ public List getParameters() { } /** - * Hook for dialects that support a `LIMIT` clause on DELETE statements + * Hook for dialects that support a {@code LIMIT} clause on DELETE statements * (for example, MySQL). The default implementation returns {@code false}, - * meaning the base renderer will ignore `limit` on `Query` for DELETE. - * Subclasses that want to enable `LIMIT` should override this method. + * meaning the base renderer will ignore {@code limit} on {@link Query} for DELETE. + * Subclasses that want to enable {@code LIMIT} should override this method. * - * @return {@code true} if the dialect appends a `LIMIT` to DELETE statements + * @return {@code true} if the dialect appends a {@code LIMIT} to DELETE statements */ protected boolean supportsDeleteLimit() { return false; @@ -93,8 +103,9 @@ public SqlResult render(Query query) { if (query.isDistinct()) { sql.append("DISTINCT "); } - appendSelectColumns(sql, query); - sql.append(" FROM ").append(quoteIdentifier(query.getTable())); + appendSelectColumns(sql, params, query); + appendFromClause(sql, params, query); + appendJoinClauses(sql, params, query); appendWhereClause(sql, params, query); if (!query.getGroupBy().isEmpty()) { final String cols = query.getGroupBy().stream() @@ -126,15 +137,62 @@ public List getParameters() { }; } - private void appendSelectColumns(StringBuilder sql, Query query) { - if (query.getSelectColumns().isEmpty()) { + private void appendSelectColumns(StringBuilder sql, List params, Query query) { + final List cols = query.getSelectColumns(); + final List subItems = query.getSelectSubqueries(); + if (cols.isEmpty() && subItems.isEmpty()) { sql.append("*"); + return; + } + final List fragments = new ArrayList<>(); + for (final String col : cols) { + fragments.add(quoteIdentifier(col)); + } + for (final ScalarSelectItem item : subItems) { + final SqlResult sub = render(item.getSubquery()); + params.addAll(sub.getParameters()); + fragments.add("(" + sub.getSql() + ") AS " + quoteIdentifier(item.getAlias())); + } + sql.append(String.join(", ", fragments)); + } + + private void appendFromClause(StringBuilder sql, List params, Query query) { + if (query.getFromSubquery() != null) { + final SqlResult sub = render(query.getFromSubquery()); + params.addAll(sub.getParameters()); + sql.append(" FROM (").append(sub.getSql()).append(") AS ") + .append(quoteIdentifier(query.getFromAlias())); } else { - sql.append(query.getSelectColumns().stream() - .map(this::quoteIdentifier).collect(Collectors.joining(", "))); + sql.append(" FROM ").append(quoteIdentifier(query.getTable())); + } + } + + private void appendJoinClauses(StringBuilder sql, List params, Query query) { + for (final JoinClause join : query.getJoins()) { + sql.append(" ").append(join.getType().name()).append(" JOIN "); + if (join.getSubquery() != null) { + final SqlResult sub = render(join.getSubquery()); + params.addAll(sub.getParameters()); + sql.append("(").append(sub.getSql()).append(") AS ") + .append(quoteIdentifier(join.getAlias())); + } else { + sql.append(quoteIdentifier(join.getTable())); + } + if (join.getOnCondition() != null) { + sql.append(" ON ").append(join.getOnCondition()); + } } } + /** + * Appends a {@code WHERE} clause to {@code sql}, collecting bound parameters into + * {@code params}. For conditions whose column is {@code null} (e.g. EXISTS subquery + * conditions), the column fragment is omitted. + * + * @param sql the SQL string builder + * @param params the bound-parameter list + * @param query the source query + */ protected void appendWhereClause(StringBuilder sql, List params, Query query) { final List conditions = query.getConditions(); if (conditions.isEmpty()) { @@ -146,27 +204,95 @@ protected void appendWhereClause(StringBuilder sql, List params, Query q if (i > 0) { sql.append(" ").append(entry.getConnector().name()).append(" "); } - sql.append(quoteIdentifier(entry.getColumn())).append(" "); + if (entry.getColumn() != null) { + sql.append(quoteIdentifier(entry.getColumn())).append(" "); + } appendConditionFragment(sql, params, entry); } } + /** + * Appends the operator and value fragment for a single condition, collecting bound + * parameters. Handles scalar values, {@link java.util.List}-valued operators, and + * {@link Query}-valued subquery operators. + * + * @param sql the SQL string builder + * @param params the bound-parameter list + * @param entry the condition entry to render + */ @SuppressWarnings("unchecked") protected void appendConditionFragment(StringBuilder sql, List params, ConditionEntry entry) { final Operator op = entry.getCondition().getOperator(); + final Object val = entry.getCondition().getValue(); + + if (val instanceof Query) { + appendSubqueryCondition(sql, params, op, (Query) val); + return; + } if (COMPARISON_OPERATORS.containsKey(op)) { sql.append(COMPARISON_OPERATORS.get(op)); - params.add(entry.getCondition().getValue()); + params.add(val); return; } + appendNonComparisonFragment(sql, params, op, val); + } + + private void appendSubqueryCondition( + StringBuilder sql, List params, Operator op, Query subquery) { + if (op == Operator.EXISTS_SUBQUERY) { + appendSubqueryExists(sql, params, subquery, false); + } else if (op == Operator.NOT_EXISTS_SUBQUERY) { + appendSubqueryExists(sql, params, subquery, true); + } else if (op == Operator.IN) { + appendSubqueryIn(sql, params, subquery, false); + } else if (op == Operator.NOT_IN) { + appendSubqueryIn(sql, params, subquery, true); + } else if (COMPARISON_OPERATORS.containsKey(op)) { + appendSubqueryComparison(sql, params, op, subquery); + } + } + + private void appendSubqueryExists( + StringBuilder sql, List params, Query subquery, boolean negate) { + final SqlResult sub = render(subquery); + params.addAll(sub.getParameters()); + if (negate) { + sql.append("NOT EXISTS (").append(sub.getSql()).append(")"); + } else { + sql.append("EXISTS (").append(sub.getSql()).append(")"); + } + } + + private void appendSubqueryIn( + StringBuilder sql, List params, Query subquery, boolean negate) { + final SqlResult sub = render(subquery); + params.addAll(sub.getParameters()); + if (negate) { + sql.append("NOT IN (").append(sub.getSql()).append(")"); + } else { + sql.append("IN (").append(sub.getSql()).append(")"); + } + } + + private void appendSubqueryComparison( + StringBuilder sql, List params, Operator op, Query subquery) { + final SqlResult sub = render(subquery); + params.addAll(sub.getParameters()); + final String fragment = COMPARISON_OPERATORS.get(op).replace("?", "(" + sub.getSql() + ")"); + sql.append(fragment); + } + + @SuppressWarnings("unchecked") + private void appendNonComparisonFragment( + StringBuilder sql, List params, Operator op, Object val) { switch (op) { case LIKE: sql.append("LIKE ?"); - params.add("%" + entry.getCondition().getValue() + "%"); + params.add("%" + val + "%"); break; case NOT_LIKE: sql.append("NOT LIKE ?"); - params.add("%" + entry.getCondition().getValue() + "%"); + params.add("%" + val + "%"); break; case EXISTS: sql.append("IS NOT NULL"); @@ -177,32 +303,34 @@ protected void appendConditionFragment(StringBuilder sql, List params, C case IS_NOT_NULL: sql.append("IS NOT NULL"); break; - case IN: { - final List inVals = (List) entry.getCondition().getValue(); - sql.append("IN (").append(String.join(", ", Collections.nCopies(inVals.size(), "?"))).append(")"); - params.addAll(inVals); + case IN: + appendInList(sql, params, (List) val, false); break; - } - case NOT_IN: { - final List notInVals = (List) entry.getCondition().getValue(); - sql.append("NOT IN (") - .append(String.join(", ", Collections.nCopies(notInVals.size(), "?"))) - .append(")"); - params.addAll(notInVals); + case NOT_IN: + appendInList(sql, params, (List) val, true); break; - } - case BETWEEN: { - final List betweenVals = (List) entry.getCondition().getValue(); + case BETWEEN: + final List between = (List) val; sql.append("BETWEEN ? AND ?"); - params.add(betweenVals.get(0)); - params.add(betweenVals.get(1)); + params.add(between.get(0)); + params.add(between.get(1)); break; - } default: break; } } + private void appendInList( + StringBuilder sql, List params, List values, boolean negate) { + final String placeholders = String.join(", ", Collections.nCopies(values.size(), "?")); + if (negate) { + sql.append("NOT IN (").append(placeholders).append(")"); + } else { + sql.append("IN (").append(placeholders).append(")"); + } + params.addAll(values); + } + private void appendOrderByClause(StringBuilder sql, Query query) { if (query.getOrderBy().isEmpty()) { return; From 1307921ed856953cc045e1f17f7524aadfc1a681 Mon Sep 17 00:00:00 2001 From: ez-plugins Date: Sat, 18 Apr 2026 14:22:14 +0200 Subject: [PATCH 2/4] feat: add subquery fluent helpers to QueryBuilder and DeleteBuilder QueryBuilder (SELECT): - whereInSubquery(col, Query): WHERE col IN (SELECT ...) - whereEqualsSubquery(col, Query): WHERE col = (SELECT ...) - whereExistsSubquery(Query): WHERE EXISTS (SELECT ...) - whereNotExistsSubquery(Query): WHERE NOT EXISTS (SELECT ...) - fromSubquery(Query, alias): FROM (SELECT ...) AS alias - joinSubquery(Query, alias, on): INNER JOIN (SELECT ...) AS alias ON ... - selectSubquery(Query, alias): (SELECT ...) AS alias in SELECT clause - build() propagates all four new Query fields (fromSubquery, fromAlias, joins, selectSubqueries) DeleteBuilder: - Remove duplicate rendering logic (SIMPLE_OPS map, handle*Operator methods) and delegate to AbstractSqlDialect.renderDelete() via new toQuery() helper - toQuery() sets limit = -1 to suppress spurious LIMIT 0 on MySQL / SQLite - whereInSubquery(col, Query): WHERE col IN (SELECT ...) - whereExistsSubquery(Query): WHERE EXISTS (SELECT ...) --- .../query/builder/DeleteBuilder.java | 151 ++++-------------- .../query/builder/QueryBuilder.java | 108 +++++++++++++ 2 files changed, 140 insertions(+), 119 deletions(-) diff --git a/src/main/java/com/github/ezframework/javaquerybuilder/query/builder/DeleteBuilder.java b/src/main/java/com/github/ezframework/javaquerybuilder/query/builder/DeleteBuilder.java index cc20cc7..362db8c 100644 --- a/src/main/java/com/github/ezframework/javaquerybuilder/query/builder/DeleteBuilder.java +++ b/src/main/java/com/github/ezframework/javaquerybuilder/query/builder/DeleteBuilder.java @@ -2,8 +2,8 @@ import java.util.ArrayList; import java.util.List; -import java.util.Map; +import com.github.ezframework.javaquerybuilder.query.Query; import com.github.ezframework.javaquerybuilder.query.condition.Condition; import com.github.ezframework.javaquerybuilder.query.condition.ConditionEntry; import com.github.ezframework.javaquerybuilder.query.condition.Connector; @@ -19,16 +19,6 @@ */ public class DeleteBuilder { - /** Simple comparison operators mapped to their SQL fragments. */ - private static final Map SIMPLE_OPS = Map.of( - Operator.EQ, " = ?", - Operator.NEQ, " != ?", - Operator.GT, " > ?", - Operator.GTE, " >= ?", - Operator.LT, " < ?", - Operator.LTE, " <= ?" - ); - /** The table to delete from. */ private String table; @@ -158,132 +148,55 @@ public DeleteBuilder whereNotEquals(String column, Object value) { } /** - * Builds the SQL DELETE statement. + * Adds a {@code WHERE col IN (SELECT ...)} condition using a subquery. * - * @return the SQL result + * @param column the column to test + * @param subquery the subquery whose result set provides the IN values + * @return this builder */ - public SqlResult build() { - return build(null); + public DeleteBuilder whereInSubquery(String column, Query subquery) { + conditions.add(new ConditionEntry(column, new Condition(Operator.IN, subquery), Connector.AND)); + return this; } /** - * Builds the SQL DELETE statement using the given dialect. + * Adds a {@code WHERE EXISTS (SELECT ...)} condition. * - * @param dialect the SQL dialect (may be null for standard SQL) - * @return the SQL result + * @param subquery the subquery to test for existence + * @return this builder */ - public SqlResult build(final SqlDialect dialect) { - final StringBuilder sql = new StringBuilder(); - final List params = new ArrayList<>(); - sql.append("DELETE FROM ").append(table); - if (!conditions.isEmpty()) { - sql.append(" WHERE "); - for (int j = 0; j < conditions.size(); j++) { - final ConditionEntry cond = conditions.get(j); - if (j > 0) { - sql.append(" ").append(cond.getConnector().name()).append(" "); - } - appendDmlCondition(sql, params, cond); - } - } - return new SqlResult() { - @Override - public String getSql() { - return sql.toString(); - } - - @Override - public List getParameters() { - return params; - } - }; - } - - private void appendDmlCondition(StringBuilder sql, List params, ConditionEntry cond) { - final Operator op = cond.getCondition().getOperator(); - sql.append(cond.getColumn()); - if (SIMPLE_OPS.containsKey(op)) { - sql.append(SIMPLE_OPS.get(op)); - params.add(cond.getCondition().getValue()); - } else if (op == Operator.IN) { - handleInOperator(sql, params, cond); - } else if (op == Operator.NOT_IN) { - handleNotInOperator(sql, params, cond); - } else if (op == Operator.BETWEEN) { - handleBetweenOperator(sql, params, cond); - } else { - sql.append(" = ?"); - params.add(cond.getCondition().getValue()); - } + public DeleteBuilder whereExistsSubquery(Query subquery) { + conditions.add(new ConditionEntry(null, + new Condition(Operator.EXISTS_SUBQUERY, subquery), Connector.AND)); + return this; } /** - * Handles the NOT IN operator for SQL generation. + * Builds the SQL DELETE statement using standard SQL. * - * @param sql the SQL string builder - * @param params the parameter list - * @param cond the condition entry - * @throws UnsupportedOperationException if the value list is null or empty + * @return the SQL result */ - private void handleNotInOperator(StringBuilder sql, List params, ConditionEntry cond) { - @SuppressWarnings("unchecked") - final List notInValues = (List) cond.getCondition().getValue(); - if (notInValues == null || notInValues.isEmpty()) { - throw new UnsupportedOperationException("NOT IN value list must not be null or empty"); - } - sql.append(" NOT IN ("); - for (int j = 0; j < notInValues.size(); j++) { - if (j > 0) { - sql.append(", "); - } - sql.append("?"); - params.add(notInValues.get(j)); - } - sql.append(")"); + public SqlResult build() { + return build(null); } /** - * Handles the BETWEEN operator for SQL generation. + * Builds the SQL DELETE statement using the given dialect. + * When {@code dialect} is {@code null}, {@link SqlDialect#STANDARD} is used. * - * @param sql the SQL string builder - * @param params the parameter list - * @param cond the condition entry - * @throws UnsupportedOperationException if the value list is null or not exactly two values + * @param dialect the SQL dialect (may be null for standard SQL) + * @return the SQL result */ - private void handleBetweenOperator(StringBuilder sql, List params, ConditionEntry cond) { - @SuppressWarnings("unchecked") - final List betweenValues = (List) cond.getCondition().getValue(); - if (betweenValues == null || betweenValues.size() != 2) { - throw new UnsupportedOperationException("BETWEEN requires exactly two values"); - } - sql.append(" BETWEEN ? AND ?"); - params.add(betweenValues.get(0)); - params.add(betweenValues.get(1)); + public SqlResult build(final SqlDialect dialect) { + final Query q = toQuery(); + return (dialect != null ? dialect : SqlDialect.STANDARD).renderDelete(q); } - /** - * Handles the IN operator for SQL generation. - * Extracted to reduce cyclomatic complexity. - * - * @param sql the SQL string builder - * @param params the list of SQL parameters - * @param cond the condition entry containing the IN values - * @throws UnsupportedOperationException if the IN value list is null or empty - */ - private void handleInOperator(StringBuilder sql, List params, ConditionEntry cond) { - @SuppressWarnings("unchecked") - final List inValues = (List) cond.getCondition().getValue(); - if (inValues == null || inValues.isEmpty()) { - throw new UnsupportedOperationException("IN value list must not be null or empty"); - } - sql.append(" IN ("); - for (int j = 0; j < inValues.size(); j++) { - if (j > 0) { - sql.append(", "); - } - sql.append("?"); - params.add(inValues.get(j)); - } - sql.append(")"); + private Query toQuery() { + final Query q = new Query(); + q.setTable(table); + q.setConditions(new ArrayList<>(conditions)); + q.setLimit(-1); + return q; } } diff --git a/src/main/java/com/github/ezframework/javaquerybuilder/query/builder/QueryBuilder.java b/src/main/java/com/github/ezframework/javaquerybuilder/query/builder/QueryBuilder.java index d3c3423..6352c51 100644 --- a/src/main/java/com/github/ezframework/javaquerybuilder/query/builder/QueryBuilder.java +++ b/src/main/java/com/github/ezframework/javaquerybuilder/query/builder/QueryBuilder.java @@ -4,7 +4,9 @@ import java.util.Arrays; import java.util.List; +import com.github.ezframework.javaquerybuilder.query.JoinClause; import com.github.ezframework.javaquerybuilder.query.Query; +import com.github.ezframework.javaquerybuilder.query.ScalarSelectItem; import com.github.ezframework.javaquerybuilder.query.condition.Condition; import com.github.ezframework.javaquerybuilder.query.condition.ConditionEntry; import com.github.ezframework.javaquerybuilder.query.condition.Connector; @@ -68,6 +70,18 @@ public class QueryBuilder { /** The source table for SELECT. */ private String table = null; + /** The FROM subquery when using a derived-table source; {@code null} for a plain table. */ + private Query fromSubquery = null; + + /** The alias for the FROM derived-table. */ + private String fromAlias = null; + + /** The JOIN clauses for the SELECT. */ + private final List joins = new ArrayList<>(); + + /** The scalar subquery items appended to the SELECT clause. */ + private final List selectSubqueries = new ArrayList<>(); + /** * Returns a new {@link InsertBuilder}. * @@ -408,6 +422,96 @@ public QueryBuilder whereLessThanOrEquals(String column, Object value) { return this; } + /** + * Adds a {@code WHERE col IN (SELECT ...)} condition using a subquery, joined with AND. + * + * @param column the column to test + * @param subquery the subquery whose result set provides the IN values + * @return this builder instance for chaining + */ + public QueryBuilder whereInSubquery(String column, Query subquery) { + conditions.add(new ConditionEntry(column, + new Condition(Operator.IN, subquery), Connector.AND)); + return this; + } + + /** + * Adds a {@code WHERE col = (SELECT ...)} condition using a subquery, joined with AND. + * + * @param column the column to test + * @param subquery the scalar subquery to compare against + * @return this builder instance for chaining + */ + public QueryBuilder whereEqualsSubquery(String column, Query subquery) { + conditions.add(new ConditionEntry(column, + new Condition(Operator.EQ, subquery), Connector.AND)); + return this; + } + + /** + * Adds a {@code WHERE EXISTS (SELECT ...)} condition, joined with AND. + * + * @param subquery the subquery to test for existence + * @return this builder instance for chaining + */ + public QueryBuilder whereExistsSubquery(Query subquery) { + conditions.add(new ConditionEntry(null, + new Condition(Operator.EXISTS_SUBQUERY, subquery), Connector.AND)); + return this; + } + + /** + * Adds a {@code WHERE NOT EXISTS (SELECT ...)} condition, joined with AND. + * + * @param subquery the subquery to test for non-existence + * @return this builder instance for chaining + */ + public QueryBuilder whereNotExistsSubquery(Query subquery) { + conditions.add(new ConditionEntry(null, + new Condition(Operator.NOT_EXISTS_SUBQUERY, subquery), Connector.AND)); + return this; + } + + /** + * Sets the FROM clause to a derived-table subquery with the given alias. + * + *

Calling this method replaces any table set via {@link #from(String)}. + * + * @param subquery the subquery to use as the FROM source + * @param alias the alias for the derived table + * @return this builder instance for chaining + */ + public QueryBuilder fromSubquery(Query subquery, String alias) { + this.fromSubquery = subquery; + this.fromAlias = alias; + return this; + } + + /** + * Adds an {@code INNER JOIN (SELECT ...) AS alias ON condition} clause. + * + * @param subquery the subquery to join against + * @param alias the alias for the derived table + * @param onCondition the raw SQL ON condition fragment + * @return this builder instance for chaining + */ + public QueryBuilder joinSubquery(Query subquery, String alias, String onCondition) { + joins.add(new JoinClause(JoinClause.Type.INNER, subquery, alias, onCondition)); + return this; + } + + /** + * Appends a scalar subquery as {@code (SELECT ...) AS alias} in the SELECT clause. + * + * @param subquery the scalar subquery to embed + * @param alias the column alias + * @return this builder instance for chaining + */ + public QueryBuilder selectSubquery(Query subquery, String alias) { + selectSubqueries.add(new ScalarSelectItem(subquery, alias)); + return this; + } + /** * Adds one or more columns to the GROUP BY clause. * @@ -482,6 +586,10 @@ public Query build() { q.setSelectColumns(new ArrayList<>(selectColumns)); q.setDistinct(isDistinct); q.setHavingRaw(havingRaw); + q.setFromSubquery(fromSubquery); + q.setFromAlias(fromAlias); + q.setJoins(new ArrayList<>(joins)); + q.setSelectSubqueries(new ArrayList<>(selectSubqueries)); return q; } From 4f5964549fbb88b46fa0c40956115a0fbae715e7 Mon Sep 17 00:00:00 2001 From: ez-plugins Date: Sat, 18 Apr 2026 14:22:27 +0200 Subject: [PATCH 3/4] test: add comprehensive subquery and SQL execution matrix tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Unit tests (54 new assertions across 7 files): - SubqueryConditionTest: WHERE IN subquery across STANDARD/MYSQL/SQLITE, WHERE NOT IN subquery, WHERE col = (SELECT ...) - ExistsSubqueryTest: EXISTS, NOT EXISTS, MySQL quoting, multi-condition params - FromSubqueryTest: FROM (SELECT ...) AS alias across all three dialects, outer WHERE combined with FROM subquery - JoinSubqueryTest: INNER JOIN subquery STANDARD/MYSQL, param ordering - ScalarSelectSubqueryTest: scalar SELECT STANDARD/MYSQL, subquery-only SELECT - NestedSubqueryParamOrderTest: deterministic param order (scalar->FROM->JOIN->WHERE), 3-level nesting - DeleteSubqueryIntegrationTest: DELETE + IN subquery STANDARD/MYSQL/SQLITE, multi-condition param ordering, DELETE + EXISTS subquery Execution matrix (SqlExecutionMatrixTest — 54 parameterized tests): - Spins up an in-memory SQLite database via sqlite-jdbc - Executes every SELECT operator (all 14 + subquery variants), INSERT, UPDATE, DELETE, and CREATE TABLE combination against a live JDBC connection - DML cases run inside savepoints that are always rolled back - pom.xml: add sqlite-jdbc:3.46.1.3 and junit-jupiter-params:6.0.3 as test-scoped dependencies --- pom.xml | 12 + .../sql/DeleteSubqueryIntegrationTest.java | 124 ++++++ .../query/sql/ExistsSubqueryTest.java | 100 +++++ .../query/sql/FromSubqueryTest.java | 93 +++++ .../query/sql/JoinSubqueryTest.java | 83 ++++ .../sql/NestedSubqueryParamOrderTest.java | 105 +++++ .../query/sql/ScalarSelectSubqueryTest.java | 77 ++++ .../query/sql/SubqueryConditionTest.java | 126 ++++++ .../feature/query/SqlExecutionMatrixTest.java | 378 ++++++++++++++++++ 9 files changed, 1098 insertions(+) create mode 100644 src/test/java/com/github/ezframework/javaquerybuilder/query/sql/DeleteSubqueryIntegrationTest.java create mode 100644 src/test/java/com/github/ezframework/javaquerybuilder/query/sql/ExistsSubqueryTest.java create mode 100644 src/test/java/com/github/ezframework/javaquerybuilder/query/sql/FromSubqueryTest.java create mode 100644 src/test/java/com/github/ezframework/javaquerybuilder/query/sql/JoinSubqueryTest.java create mode 100644 src/test/java/com/github/ezframework/javaquerybuilder/query/sql/NestedSubqueryParamOrderTest.java create mode 100644 src/test/java/com/github/ezframework/javaquerybuilder/query/sql/ScalarSelectSubqueryTest.java create mode 100644 src/test/java/com/github/ezframework/javaquerybuilder/query/sql/SubqueryConditionTest.java create mode 100644 src/test/java/feature/query/SqlExecutionMatrixTest.java diff --git a/pom.xml b/pom.xml index e996fbd..186c300 100644 --- a/pom.xml +++ b/pom.xml @@ -31,6 +31,18 @@ 6.0.3 test + + org.junit.jupiter + junit-jupiter-params + 6.0.3 + test + + + org.xerial + sqlite-jdbc + 3.46.1.3 + test + diff --git a/src/test/java/com/github/ezframework/javaquerybuilder/query/sql/DeleteSubqueryIntegrationTest.java b/src/test/java/com/github/ezframework/javaquerybuilder/query/sql/DeleteSubqueryIntegrationTest.java new file mode 100644 index 0000000..210e7a7 --- /dev/null +++ b/src/test/java/com/github/ezframework/javaquerybuilder/query/sql/DeleteSubqueryIntegrationTest.java @@ -0,0 +1,124 @@ +package com.github.ezframework.javaquerybuilder.query.sql; + +import java.util.List; + +import com.github.ezframework.javaquerybuilder.query.Query; +import com.github.ezframework.javaquerybuilder.query.builder.DeleteBuilder; +import com.github.ezframework.javaquerybuilder.query.builder.QueryBuilder; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertIterableEquals; + +/** + * Integration tests for the Jaloquent use-case: DELETE with subquery IN condition. + * + *

Demonstrates that {@link DeleteBuilder#whereInSubquery(String, Query)} produces + * a single DELETE statement with correct parameterised SQL across all dialects, + * replacing the substring-hack approach previously used in Jaloquent. + */ +public class DeleteSubqueryIntegrationTest { + + @Test + void deleteWhereInSubquery_standard() { + final Query subquery = new QueryBuilder() + .from("other") + .select("id") + .whereEquals("status", "obsolete") + .build(); + + final SqlResult result = new DeleteBuilder() + .from("t") + .whereInSubquery("id", subquery) + .build(SqlDialect.STANDARD); + + assertEquals( + "DELETE FROM t WHERE id IN (SELECT id FROM other WHERE status = ?)", + result.getSql() + ); + assertIterableEquals(List.of("obsolete"), result.getParameters()); + } + + @Test + void deleteWhereInSubquery_mysql_quotesAll() { + final Query subquery = new QueryBuilder() + .from("archive") + .select("user_id") + .whereEquals("year", 2023) + .build(); + + final SqlResult result = new DeleteBuilder() + .from("users") + .whereInSubquery("id", subquery) + .build(SqlDialect.MYSQL); + + assertEquals( + "DELETE FROM `users` WHERE `id` IN (SELECT `user_id` FROM `archive` WHERE `year` = ?)", + result.getSql() + ); + assertIterableEquals(List.of(2023), result.getParameters()); + } + + @Test + void deleteWhereInSubquery_sqlite_quotesAll() { + final Query subquery = new QueryBuilder() + .from("removed") + .select("ref") + .whereEquals("flag", 1) + .build(); + + final SqlResult result = new DeleteBuilder() + .from("items") + .whereInSubquery("id", subquery) + .build(SqlDialect.SQLITE); + + assertEquals( + "DELETE FROM \"items\" WHERE \"id\" IN (SELECT \"ref\" FROM \"removed\" WHERE \"flag\" = ?)", + result.getSql() + ); + assertIterableEquals(List.of(1), result.getParameters()); + } + + @Test + void deleteWhereScalarAndSubquery_paramsOrdered() { + final Query subquery = new QueryBuilder() + .from("blacklist") + .select("uid") + .whereEquals("reason", "fraud") + .build(); + + final SqlResult result = new DeleteBuilder() + .from("accounts") + .whereEquals("active", false) + .whereInSubquery("user_id", subquery) + .build(SqlDialect.STANDARD); + + assertEquals( + "DELETE FROM accounts WHERE active = ? AND user_id IN " + + "(SELECT uid FROM blacklist WHERE reason = ?)", + result.getSql() + ); + assertIterableEquals(List.of(false, "fraud"), result.getParameters()); + } + + @Test + void deleteWhereExistsSubquery() { + final Query subquery = new QueryBuilder() + .from("orders") + .select("id") + .whereEquals("account_id", 99) + .build(); + + final SqlResult result = new DeleteBuilder() + .from("pending") + .whereExistsSubquery(subquery) + .build(SqlDialect.STANDARD); + + assertEquals( + "DELETE FROM pending WHERE EXISTS (SELECT id FROM orders WHERE account_id = ?)", + result.getSql() + ); + assertIterableEquals(List.of(99), result.getParameters()); + } +} diff --git a/src/test/java/com/github/ezframework/javaquerybuilder/query/sql/ExistsSubqueryTest.java b/src/test/java/com/github/ezframework/javaquerybuilder/query/sql/ExistsSubqueryTest.java new file mode 100644 index 0000000..3ecc529 --- /dev/null +++ b/src/test/java/com/github/ezframework/javaquerybuilder/query/sql/ExistsSubqueryTest.java @@ -0,0 +1,100 @@ +package com.github.ezframework.javaquerybuilder.query.sql; + +import java.util.List; + +import com.github.ezframework.javaquerybuilder.query.Query; +import com.github.ezframework.javaquerybuilder.query.builder.QueryBuilder; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertIterableEquals; + +/** + * Tests for EXISTS / NOT EXISTS subquery conditions. + */ +public class ExistsSubqueryTest { + + @Test + void whereExistsSubquery_standard() { + final Query inner = new QueryBuilder() + .from("orders") + .select("id") + .whereEquals("user_id", 42) + .build(); + + final SqlResult result = new QueryBuilder() + .from("users") + .whereExistsSubquery(inner) + .buildSql("users", SqlDialect.STANDARD); + + assertEquals( + "SELECT * FROM users WHERE EXISTS (SELECT id FROM orders WHERE user_id = ?)", + result.getSql() + ); + assertIterableEquals(List.of(42), result.getParameters()); + } + + @Test + void whereNotExistsSubquery_standard() { + final Query inner = new QueryBuilder() + .from("bans") + .select("user_id") + .whereEquals("active", true) + .build(); + + final SqlResult result = new QueryBuilder() + .from("users") + .whereNotExistsSubquery(inner) + .buildSql("users", SqlDialect.STANDARD); + + assertEquals( + "SELECT * FROM users WHERE NOT EXISTS (SELECT user_id FROM bans WHERE active = ?)", + result.getSql() + ); + assertIterableEquals(List.of(true), result.getParameters()); + } + + @Test + void whereExistsSubquery_mysql_quotesIdentifiers() { + final Query inner = new QueryBuilder() + .from("subscriptions") + .select("uid") + .whereEquals("plan", "pro") + .build(); + + final SqlResult result = new QueryBuilder() + .from("accounts") + .whereExistsSubquery(inner) + .buildSql("accounts", SqlDialect.MYSQL); + + assertEquals( + "SELECT * FROM `accounts` WHERE EXISTS " + + "(SELECT `uid` FROM `subscriptions` WHERE `plan` = ?)", + result.getSql() + ); + assertIterableEquals(List.of("pro"), result.getParameters()); + } + + @Test + void existsSubquery_paramsFromMultipleConditions() { + final Query inner = new QueryBuilder() + .from("events") + .select("user_id") + .whereEquals("type", "login") + .build(); + + final SqlResult result = new QueryBuilder() + .from("users") + .whereEquals("status", "active") + .whereExistsSubquery(inner) + .buildSql("users", SqlDialect.STANDARD); + + assertEquals( + "SELECT * FROM users WHERE status = ? AND EXISTS " + + "(SELECT user_id FROM events WHERE type = ?)", + result.getSql() + ); + assertIterableEquals(List.of("active", "login"), result.getParameters()); + } +} diff --git a/src/test/java/com/github/ezframework/javaquerybuilder/query/sql/FromSubqueryTest.java b/src/test/java/com/github/ezframework/javaquerybuilder/query/sql/FromSubqueryTest.java new file mode 100644 index 0000000..6f182fb --- /dev/null +++ b/src/test/java/com/github/ezframework/javaquerybuilder/query/sql/FromSubqueryTest.java @@ -0,0 +1,93 @@ +package com.github.ezframework.javaquerybuilder.query.sql; + +import java.util.List; + +import com.github.ezframework.javaquerybuilder.query.Query; +import com.github.ezframework.javaquerybuilder.query.builder.QueryBuilder; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertIterableEquals; + +/** + * Tests for FROM (SELECT ...) AS alias derived-table queries. + */ +public class FromSubqueryTest { + + @Test + void fromSubquery_standard() { + final Query inner = new QueryBuilder() + .from("raw_data") + .select("id", "value") + .whereEquals("archived", false) + .build(); + + final SqlResult result = new QueryBuilder() + .fromSubquery(inner, "d") + .select("id", "value") + .buildSql(null, SqlDialect.STANDARD); + + assertEquals( + "SELECT id, value FROM (SELECT id, value FROM raw_data WHERE archived = ?) AS d", + result.getSql() + ); + assertIterableEquals(List.of(false), result.getParameters()); + } + + @Test + void fromSubquery_mysql_quotesAlias() { + final Query inner = new QueryBuilder() + .from("logs") + .select("user_id") + .build(); + + final SqlResult result = new QueryBuilder() + .fromSubquery(inner, "recent") + .select("user_id") + .buildSql(null, SqlDialect.MYSQL); + + assertEquals( + "SELECT `user_id` FROM (SELECT `user_id` FROM `logs`) AS `recent`", + result.getSql() + ); + assertIterableEquals(List.of(), result.getParameters()); + } + + @Test + void fromSubquery_sqlite_quotesAlias() { + final Query inner = new QueryBuilder() + .from("events") + .select("ts", "user_id") + .build(); + + final SqlResult result = new QueryBuilder() + .fromSubquery(inner, "ev") + .buildSql(null, SqlDialect.SQLITE); + + assertEquals( + "SELECT * FROM (SELECT \"ts\", \"user_id\" FROM \"events\") AS \"ev\"", + result.getSql() + ); + } + + @Test + void fromSubquery_withWhereOnOuter() { + final Query inner = new QueryBuilder() + .from("sales") + .select("region", "amount") + .build(); + + final SqlResult result = new QueryBuilder() + .fromSubquery(inner, "s") + .select("region") + .whereEquals("amount", 100) + .buildSql(null, SqlDialect.STANDARD); + + assertEquals( + "SELECT region FROM (SELECT region, amount FROM sales) AS s WHERE amount = ?", + result.getSql() + ); + assertIterableEquals(List.of(100), result.getParameters()); + } +} diff --git a/src/test/java/com/github/ezframework/javaquerybuilder/query/sql/JoinSubqueryTest.java b/src/test/java/com/github/ezframework/javaquerybuilder/query/sql/JoinSubqueryTest.java new file mode 100644 index 0000000..19de039 --- /dev/null +++ b/src/test/java/com/github/ezframework/javaquerybuilder/query/sql/JoinSubqueryTest.java @@ -0,0 +1,83 @@ +package com.github.ezframework.javaquerybuilder.query.sql; + +import java.util.List; + +import com.github.ezframework.javaquerybuilder.query.Query; +import com.github.ezframework.javaquerybuilder.query.builder.QueryBuilder; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertIterableEquals; + +/** + * Tests for JOIN (SELECT ...) AS alias subquery joins. + */ +public class JoinSubqueryTest { + + @Test + void joinSubquery_standard() { + final Query inner = new QueryBuilder() + .from("order_totals") + .select("user_id", "total") + .whereEquals("year", 2025) + .build(); + + final SqlResult result = new QueryBuilder() + .from("users") + .select("users.id", "ot.total") + .joinSubquery(inner, "ot", "users.id = ot.user_id") + .buildSql("users", SqlDialect.STANDARD); + + assertEquals( + "SELECT users.id, ot.total FROM users INNER JOIN " + + "(SELECT user_id, total FROM order_totals WHERE year = ?) AS ot ON users.id = ot.user_id", + result.getSql() + ); + assertIterableEquals(List.of(2025), result.getParameters()); + } + + @Test + void joinSubquery_mysql_quotesAlias() { + final Query inner = new QueryBuilder() + .from("scores") + .select("player_id", "pts") + .build(); + + final SqlResult result = new QueryBuilder() + .from("players") + .joinSubquery(inner, "sc", "players.id = sc.player_id") + .buildSql("players", SqlDialect.MYSQL); + + assertEquals( + "SELECT * FROM `players` INNER JOIN (SELECT `player_id`, `pts` FROM `scores`) AS `sc`" + + " ON players.id = sc.player_id", + result.getSql() + ); + } + + @Test + void joinSubquery_paramsInCorrectOrder() { + final Query inner = new QueryBuilder() + .from("stats") + .select("uid") + .whereEquals("month", "2025-01") + .build(); + + final SqlResult result = new QueryBuilder() + .from("users") + .whereEquals("active", true) + .joinSubquery(inner, "s", "users.id = s.uid") + .buildSql("users", SqlDialect.STANDARD); + + // JOIN params appear before WHERE params in the rendered list + // because JOINs are appended to the SQL and params before WHERE + assertEquals( + "SELECT * FROM users INNER JOIN " + + "(SELECT uid FROM stats WHERE month = ?) AS s ON users.id = s.uid" + + " WHERE active = ?", + result.getSql() + ); + assertIterableEquals(List.of("2025-01", true), result.getParameters()); + } +} diff --git a/src/test/java/com/github/ezframework/javaquerybuilder/query/sql/NestedSubqueryParamOrderTest.java b/src/test/java/com/github/ezframework/javaquerybuilder/query/sql/NestedSubqueryParamOrderTest.java new file mode 100644 index 0000000..8d54dbe --- /dev/null +++ b/src/test/java/com/github/ezframework/javaquerybuilder/query/sql/NestedSubqueryParamOrderTest.java @@ -0,0 +1,105 @@ +package com.github.ezframework.javaquerybuilder.query.sql; + +import java.util.List; + +import com.github.ezframework.javaquerybuilder.query.Query; +import com.github.ezframework.javaquerybuilder.query.builder.QueryBuilder; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertIterableEquals; + +/** + * Tests verifying deterministic parameter ordering across nested and combined subqueries. + * + *

Contract: parameters are emitted in document order: + * scalar SELECT params → FROM params → JOIN params → WHERE params. + */ +public class NestedSubqueryParamOrderTest { + + @Test + void scalarThenWhere_paramOrder() { + final Query scalar = new QueryBuilder() + .from("a").select("x").whereEquals("ak", "A").build(); + + final SqlResult result = new QueryBuilder() + .from("t") + .select("col") + .selectSubquery(scalar, "alias") + .whereEquals("col", "W") + .buildSql("t", SqlDialect.STANDARD); + + assertIterableEquals(List.of("A", "W"), result.getParameters()); + } + + @Test + void fromSubqueryThenWhere_paramOrder() { + final Query fromQ = new QueryBuilder() + .from("inner_t").select("v").whereEquals("fk", "F").build(); + + final SqlResult result = new QueryBuilder() + .fromSubquery(fromQ, "d") + .whereEquals("v", "W") + .buildSql(null, SqlDialect.STANDARD); + + assertIterableEquals(List.of("F", "W"), result.getParameters()); + } + + @Test + void joinThenWhere_paramOrder() { + final Query joinQ = new QueryBuilder() + .from("j").select("jid").whereEquals("jk", "J").build(); + + final SqlResult result = new QueryBuilder() + .from("t") + .joinSubquery(joinQ, "jt", "t.id = jt.jid") + .whereEquals("t.status", "active") + .buildSql("t", SqlDialect.STANDARD); + + assertIterableEquals(List.of("J", "active"), result.getParameters()); + } + + @Test + void scalarFromJoinWhere_fullOrder() { + final Query scalar = new QueryBuilder() + .from("s").select("sv").whereEquals("sk", "S").build(); + final Query fromQ = new QueryBuilder() + .from("f").select("fv").whereEquals("fk", "F").build(); + final Query joinQ = new QueryBuilder() + .from("jt").select("jv").whereEquals("jk", "J").build(); + + final SqlResult result = new QueryBuilder() + .fromSubquery(fromQ, "d") + .select("d.fv") + .selectSubquery(scalar, "sv") + .joinSubquery(joinQ, "jd", "d.fv = jd.jv") + .whereEquals("d.fv", "W") + .buildSql(null, SqlDialect.STANDARD); + + // expected order: scalar(S), FROM(F), JOIN(J), WHERE(W) + assertIterableEquals(List.of("S", "F", "J", "W"), result.getParameters()); + } + + @Test + void nestedSubquery_threeLevel_paramOrder() { + // level-3 innermost + final Query l3 = new QueryBuilder() + .from("deep").select("id").whereEquals("lv", 3).build(); + // level-2 + final Query l2 = new QueryBuilder() + .from("mid").select("id").whereInSubquery("ref", l3).build(); + // level-1 outer WHERE IN subquery + final SqlResult result = new QueryBuilder() + .from("top") + .whereInSubquery("id", l2) + .buildSql("top", SqlDialect.STANDARD); + + assertEquals( + "SELECT * FROM top WHERE id IN " + + "(SELECT id FROM mid WHERE ref IN (SELECT id FROM deep WHERE lv = ?))", + result.getSql() + ); + assertIterableEquals(List.of(3), result.getParameters()); + } +} diff --git a/src/test/java/com/github/ezframework/javaquerybuilder/query/sql/ScalarSelectSubqueryTest.java b/src/test/java/com/github/ezframework/javaquerybuilder/query/sql/ScalarSelectSubqueryTest.java new file mode 100644 index 0000000..e412645 --- /dev/null +++ b/src/test/java/com/github/ezframework/javaquerybuilder/query/sql/ScalarSelectSubqueryTest.java @@ -0,0 +1,77 @@ +package com.github.ezframework.javaquerybuilder.query.sql; + +import java.util.List; + +import com.github.ezframework.javaquerybuilder.query.Query; +import com.github.ezframework.javaquerybuilder.query.builder.QueryBuilder; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertIterableEquals; + +/** + * Tests for scalar SELECT subqueries: SELECT col, (SELECT ...) AS alias. + */ +public class ScalarSelectSubqueryTest { + + @Test + void scalarSelectSubquery_standard() { + final Query inner = new QueryBuilder() + .from("order_counts") + .select("cnt") + .whereEquals("user_id", 1) + .build(); + + final SqlResult result = new QueryBuilder() + .from("users") + .select("id", "name") + .selectSubquery(inner, "order_count") + .buildSql("users", SqlDialect.STANDARD); + + assertEquals( + "SELECT id, name, " + + "(SELECT cnt FROM order_counts WHERE user_id = ?) AS order_count FROM users", + result.getSql() + ); + // scalar subquery params come first (before FROM/WHERE params) + assertIterableEquals(List.of(1), result.getParameters()); + } + + @Test + void scalarSelectSubquery_mysql_quotesAlias() { + final Query inner = new QueryBuilder() + .from("totals") + .select("amount") + .whereEquals("type", "sale") + .build(); + + final SqlResult result = new QueryBuilder() + .from("customers") + .select("id") + .selectSubquery(inner, "sales_total") + .buildSql("customers", SqlDialect.MYSQL); + + assertEquals( + "SELECT `id`, (SELECT `amount` FROM `totals` WHERE `type` = ?) AS `sales_total` FROM `customers`", + result.getSql() + ); + assertIterableEquals(List.of("sale"), result.getParameters()); + } + + @Test + void scalarSelectSubquery_onlySubquery_noPlainCols() { + final Query inner = new QueryBuilder() + .from("meta") + .select("val") + .build(); + + final SqlResult result = new QueryBuilder() + .from("x") + .selectSubquery(inner, "m") + .buildSql("x", SqlDialect.STANDARD); + + assertEquals("SELECT (SELECT val FROM meta) AS m FROM x", result.getSql()); + assertIterableEquals(List.of(), result.getParameters()); + } +} diff --git a/src/test/java/com/github/ezframework/javaquerybuilder/query/sql/SubqueryConditionTest.java b/src/test/java/com/github/ezframework/javaquerybuilder/query/sql/SubqueryConditionTest.java new file mode 100644 index 0000000..4381346 --- /dev/null +++ b/src/test/java/com/github/ezframework/javaquerybuilder/query/sql/SubqueryConditionTest.java @@ -0,0 +1,126 @@ +package com.github.ezframework.javaquerybuilder.query.sql; + +import java.util.List; + +import com.github.ezframework.javaquerybuilder.query.Query; +import com.github.ezframework.javaquerybuilder.query.builder.QueryBuilder; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertIterableEquals; + +/** + * Tests for IN/NOT IN with a subquery as the RHS value. + */ +public class SubqueryConditionTest { + + @Test + void inSubquery_standardDialect() { + final Query inner = new QueryBuilder() + .from("orders") + .select("customer_id") + .whereEquals("status", "active") + .build(); + + final SqlResult result = new QueryBuilder() + .from("customers") + .whereInSubquery("id", inner) + .buildSql("customers", SqlDialect.STANDARD); + + assertEquals( + "SELECT * FROM customers WHERE id IN (SELECT customer_id FROM orders WHERE status = ?)", + result.getSql() + ); + assertIterableEquals(List.of("active"), result.getParameters()); + } + + @Test + void notInSubquery_standardDialect() { + final Query inner = new QueryBuilder() + .from("banned") + .select("user_id") + .build(); + + final Query outer = new QueryBuilder() + .from("users") + .select("id", "name") + .build(); + + outer.getConditions().add( + new com.github.ezframework.javaquerybuilder.query.condition.ConditionEntry( + "id", + new com.github.ezframework.javaquerybuilder.query.condition.Condition( + com.github.ezframework.javaquerybuilder.query.condition.Operator.NOT_IN, inner), + com.github.ezframework.javaquerybuilder.query.condition.Connector.AND + ) + ); + + final SqlResult r = SqlDialect.STANDARD.render(outer); + assertEquals( + "SELECT id, name FROM users WHERE id NOT IN (SELECT user_id FROM banned)", + r.getSql() + ); + assertIterableEquals(List.of(), r.getParameters()); + } + + @Test + void inSubqueryWithParamsFromBoth_mysql() { + final Query inner = new QueryBuilder() + .from("sessions") + .select("user_id") + .whereEquals("type", "premium") + .build(); + + final SqlResult result = new QueryBuilder() + .from("users") + .whereEquals("active", true) + .whereInSubquery("id", inner) + .buildSql("users", SqlDialect.MYSQL); + + assertEquals( + "SELECT * FROM `users` WHERE `active` = ? AND `id` IN " + + "(SELECT `user_id` FROM `sessions` WHERE `type` = ?)", + result.getSql() + ); + assertIterableEquals(List.of(true, "premium"), result.getParameters()); + } + + @Test + void equalsSubquery() { + final Query inner = new QueryBuilder() + .from("config") + .select("value") + .whereEquals("key", "max_age") + .build(); + + final SqlResult result = new QueryBuilder() + .from("users") + .whereEqualsSubquery("age", inner) + .buildSql("users", SqlDialect.STANDARD); + + assertEquals( + "SELECT * FROM users WHERE age = (SELECT value FROM config WHERE key = ?)", + result.getSql() + ); + assertIterableEquals(List.of("max_age"), result.getParameters()); + } + + @Test + void inSubquery_sqlite_quotesAlias() { + final Query inner = new QueryBuilder() + .from("src") + .select("ref_id") + .build(); + + final SqlResult result = new QueryBuilder() + .from("main") + .whereInSubquery("id", inner) + .buildSql("main", SqlDialect.SQLITE); + + assertEquals( + "SELECT * FROM \"main\" WHERE \"id\" IN (SELECT \"ref_id\" FROM \"src\")", + result.getSql() + ); + } +} diff --git a/src/test/java/feature/query/SqlExecutionMatrixTest.java b/src/test/java/feature/query/SqlExecutionMatrixTest.java new file mode 100644 index 0000000..441b7ab --- /dev/null +++ b/src/test/java/feature/query/SqlExecutionMatrixTest.java @@ -0,0 +1,378 @@ +package feature.query; + +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.PreparedStatement; +import java.sql.SQLException; +import java.sql.Savepoint; +import java.sql.Statement; +import java.util.List; +import java.util.stream.Stream; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +import com.github.ezframework.javaquerybuilder.query.JoinClause; +import com.github.ezframework.javaquerybuilder.query.Query; +import com.github.ezframework.javaquerybuilder.query.builder.QueryBuilder; +import com.github.ezframework.javaquerybuilder.query.condition.Condition; +import com.github.ezframework.javaquerybuilder.query.condition.ConditionEntry; +import com.github.ezframework.javaquerybuilder.query.condition.Connector; +import com.github.ezframework.javaquerybuilder.query.condition.Operator; +import com.github.ezframework.javaquerybuilder.query.sql.SqlDialect; +import com.github.ezframework.javaquerybuilder.query.sql.SqlResult; + +/** + * Execution matrix: builds every SQL variant supported by the builders and executes + * it against an in-memory SQLite database to confirm valid, parseable SQL for every + * operator / clause combination across SELECT, INSERT, UPDATE, DELETE, and CREATE TABLE. + * + *

Schema and seed data are committed once in {@link #setUpDatabase()}. All DML test + * cases run inside a savepoint that is rolled back after the assertion, leaving the + * database state intact for the full test run. + */ +public class SqlExecutionMatrixTest { + + private static final SqlDialect DB = SqlDialect.SQLITE; + + private static Connection conn; + + @BeforeAll + static void setUpDatabase() throws SQLException { + conn = DriverManager.getConnection("jdbc:sqlite::memory:"); + conn.setAutoCommit(false); + exec("CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT NOT NULL," + + " email TEXT, age INTEGER, score REAL, active INTEGER, status TEXT)"); + exec("CREATE TABLE orders (id INTEGER PRIMARY KEY, user_id INTEGER," + + " amount REAL, status TEXT)"); + // Seed: permanent read-only rows used by SELECT tests + exec("INSERT INTO users VALUES (1,'Alice','alice@example.com',30,150.0,1,'active')"); + exec("INSERT INTO users VALUES (2,'Bob','bob@other.com',25,80.0,0,'inactive')"); + exec("INSERT INTO users VALUES (3,'Carol',NULL,35,200.0,1,'active')"); + exec("INSERT INTO users VALUES (4,'Dave','dave@example.com',22,60.0,1,'active')"); + exec("INSERT INTO users VALUES (5,'Eve','eve@other.com',40,300.0,1,'active')"); + exec("INSERT INTO orders VALUES (1,1,99.50,'shipped')"); + exec("INSERT INTO orders VALUES (2,1,149.00,'pending')"); + exec("INSERT INTO orders VALUES (3,2,55.00,'cancelled')"); + exec("INSERT INTO orders VALUES (4,3,200.00,'shipped')"); + exec("INSERT INTO orders VALUES (5,4,10.00,'pending')"); + // DML workspace rows exclusively targeted by UPDATE/DELETE cases + exec("INSERT INTO users VALUES (100,'Tmp1',NULL,20,0.0,0,'temp')"); + exec("INSERT INTO users VALUES (101,'Tmp2',NULL,21,0.0,0,'temp')"); + exec("INSERT INTO users VALUES (102,'Tmp3',NULL,22,0.0,0,'temp')"); + exec("INSERT INTO users VALUES (103,'Tmp4',NULL,23,0.0,0,'temp')"); + exec("INSERT INTO orders VALUES (100,100,0.0,'temp')"); + exec("INSERT INTO orders VALUES (101,101,0.0,'temp')"); + conn.commit(); + } + + @AfterAll + static void closeDatabase() throws SQLException { + if (conn != null) { + conn.close(); + } + } + + // ------------------------------------------------------------------------- + // SELECT matrix + // ------------------------------------------------------------------------- + + @ParameterizedTest(name = "[{index}] SELECT - {0}") + @MethodSource("selectCases") + void selectMatrixRuns(String name, SqlResult r) throws SQLException { + run(r); + } + + static Stream selectCases() { + return Stream.of( + c("*", b().from("users").buildSql("users", DB)), + c("columns", b().select("id", "name").from("users").buildSql("users", DB)), + c("DISTINCT", b().select("status").distinct().from("users").buildSql("users", DB)), + c("WHERE EQ", + b().from("users").whereEquals("status", "active").buildSql("users", DB)), + c("WHERE NEQ", withCond("status", Operator.NEQ, "active")), + c("WHERE GT", + b().from("users").whereGreaterThan("age", 20).buildSql("users", DB)), + c("WHERE GTE", + b().from("users").whereGreaterThanOrEquals("age", 20).buildSql("users", DB)), + c("WHERE LT", withCond("age", Operator.LT, 35)), + c("WHERE LTE", + b().from("users").whereLessThanOrEquals("age", 40).buildSql("users", DB)), + c("WHERE LIKE", + b().from("users").whereLike("email", "example").buildSql("users", DB)), + c("WHERE NOT LIKE", + b().from("users").whereNotLike("email", "other").buildSql("users", DB)), + c("WHERE IS NULL", + b().from("users").whereNull("email").buildSql("users", DB)), + c("WHERE IS NOT NULL", + b().from("users").whereNotNull("email").buildSql("users", DB)), + c("WHERE EXISTS (IS NOT NULL)", + b().from("users").whereExists("email").buildSql("users", DB)), + c("WHERE IN list", + b().from("users") + .whereIn("status", List.of("active", "inactive")).buildSql("users", DB)), + c("WHERE NOT IN list", + b().from("users") + .whereNotIn("id", List.of(99, 100, 101)).buildSql("users", DB)), + c("WHERE BETWEEN", + b().from("users").whereBetween("age", 20, 40).buildSql("users", DB)), + c("WHERE AND", + b().from("users") + .whereEquals("active", 1).whereEquals("status", "active") + .buildSql("users", DB)), + c("WHERE OR", + b().from("users") + .whereEquals("status", "active").orWhereEquals("status", "inactive") + .buildSql("users", DB)), + c("GROUP BY", + b().select("status").from("users").groupBy("status").buildSql("users", DB)), + c("HAVING", + b().select("status").from("users").groupBy("status") + .havingRaw("COUNT(*) >= 0").buildSql("users", DB)), + c("ORDER BY ASC", + b().from("users").orderBy("name", true).buildSql("users", DB)), + c("ORDER BY DESC", + b().from("users").orderBy("age", false).buildSql("users", DB)), + c("LIMIT", b().from("users").limit(2).buildSql("users", DB)), + c("LIMIT OFFSET", + b().from("users").limit(10).offset(1).buildSql("users", DB)), + c("WHERE IN subquery", + b().from("users") + .whereInSubquery("id", + b().select("user_id").from("orders") + .whereEquals("status", "shipped").build()) + .buildSql("users", DB)), + c("WHERE NOT IN subquery", + withCond("id", Operator.NOT_IN, + b().select("user_id").from("orders") + .whereEquals("status", "shipped").build())), + c("WHERE EQ subquery", + b().from("users") + .whereEqualsSubquery("id", + b().select("user_id").from("orders").whereEquals("id", 1).build()) + .buildSql("users", DB)), + c("WHERE EXISTS subquery", + b().from("users") + .whereExistsSubquery( + b().select("id").from("orders") + .whereEquals("status", "shipped").build()) + .buildSql("users", DB)), + c("WHERE NOT EXISTS subquery", + b().from("users") + .whereNotExistsSubquery( + b().select("id").from("orders").whereEquals("status", "none").build()) + .buildSql("users", DB)), + c("FROM subquery", + b().select("name") + .fromSubquery(b().select("name").from("users").build(), "u") + .buildSql(null, DB)), + c("INNER JOIN table", joinTable()), + c("INNER JOIN subquery", + b().from("users") + .joinSubquery( + b().select("user_id", "amount").from("orders").build(), + "o", "users.id = o.user_id") + .buildSql("users", DB)), + c("Scalar SELECT subquery", + b().select("id") + .selectSubquery( + b().select("id").from("orders").limit(1).build(), "frst") + .from("users").buildSql("users", DB)), + c("Complex", + b().select("id", "name").from("users") + .whereEquals("active", 1).whereGreaterThan("age", 18) + .orderBy("name", true).limit(5).buildSql("users", DB)) + ); + } + + // ------------------------------------------------------------------------- + // INSERT matrix + // ------------------------------------------------------------------------- + + @ParameterizedTest(name = "[{index}] INSERT - {0}") + @MethodSource("insertCases") + void insertMatrixRuns(String name, SqlResult r) throws SQLException { + runInTx(r); + } + + static Stream insertCases() { + return Stream.of( + c("single row", + QueryBuilder.insertInto("users") + .value("id", 200).value("name", "MatIns1") + .value("email", "m1@test.com").value("age", 20) + .value("score", 10.0).value("active", 1).value("status", "active") + .build()), + c("multi-column", + QueryBuilder.insertInto("orders") + .value("id", 200).value("user_id", 1) + .value("amount", 50.0).value("status", "pending") + .build()) + ); + } + + // ------------------------------------------------------------------------- + // UPDATE matrix + // ------------------------------------------------------------------------- + + @ParameterizedTest(name = "[{index}] UPDATE - {0}") + @MethodSource("updateCases") + void updateMatrixRuns(String name, SqlResult r) throws SQLException { + runInTx(r); + } + + static Stream updateCases() { + return Stream.of( + c("WHERE EQ", + QueryBuilder.update("users") + .set("status", "active").whereEquals("id", 100).build()), + c("WHERE GTE", + QueryBuilder.update("users") + .set("active", 1).whereGreaterThanOrEquals("id", 100).build()), + c("WHERE OR", + QueryBuilder.update("users") + .set("status", "temp").whereEquals("id", 101).orWhereEquals("id", 102) + .build()), + c("multi-SET", + QueryBuilder.update("users") + .set("score", 99.0).set("active", 0).whereEquals("id", 103).build()) + ); + } + + // ------------------------------------------------------------------------- + // DELETE matrix + // ------------------------------------------------------------------------- + + @ParameterizedTest(name = "[{index}] DELETE - {0}") + @MethodSource("deleteCases") + void deleteMatrixRuns(String name, SqlResult r) throws SQLException { + runInTx(r); + } + + static Stream deleteCases() { + return Stream.of( + c("WHERE EQ", + QueryBuilder.deleteFrom("users").whereEquals("id", 100).build(DB)), + c("WHERE LT", + QueryBuilder.deleteFrom("orders").whereLessThan("amount", 1.0).build(DB)), + c("WHERE IN list", + QueryBuilder.deleteFrom("users") + .whereIn("id", List.of(101, 102)).build(DB)), + c("WHERE NOT IN list", + QueryBuilder.deleteFrom("users") + .whereNotIn("status", List.of("active", "inactive")).build(DB)), + c("WHERE BETWEEN", + QueryBuilder.deleteFrom("users").whereBetween("id", 103, 103).build(DB)), + c("WHERE NEQ", + QueryBuilder.deleteFrom("users").whereNotEquals("status", "active").build(DB)), + c("WHERE GT", + QueryBuilder.deleteFrom("users").whereGreaterThan("id", 200).build(DB)), + c("WHERE GTE", + QueryBuilder.deleteFrom("users").whereGreaterThanOrEquals("id", 201).build(DB)), + c("WHERE LTE", + QueryBuilder.deleteFrom("users").whereLessThanOrEquals("id", 0).build(DB)), + c("WHERE IN subquery", + QueryBuilder.deleteFrom("users") + .whereInSubquery("id", + b().select("user_id").from("orders") + .whereEquals("status", "temp").build()) + .build(DB)), + c("WHERE EXISTS subquery", + QueryBuilder.deleteFrom("users") + .whereExistsSubquery( + b().select("id").from("users").whereEquals("id", 9999).build()) + .build(DB)) + ); + } + + // ------------------------------------------------------------------------- + // CREATE TABLE matrix + // ------------------------------------------------------------------------- + + @ParameterizedTest(name = "[{index}] CREATE - {0}") + @MethodSource("createCases") + void createMatrixRuns(String name, SqlResult r) throws SQLException { + runInTx(r); + } + + static Stream createCases() { + return Stream.of( + c("IF NOT EXISTS", + QueryBuilder.createTable("mat_test_1") + .column("id", "INTEGER PRIMARY KEY").column("name", "TEXT") + .ifNotExists().build()), + c("with PRIMARY KEY", + QueryBuilder.createTable("mat_test_2") + .column("id", "INTEGER").column("val", "TEXT") + .primaryKey("id").build()) + ); + } + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + private static Arguments c(String name, SqlResult r) { + return Arguments.of(name, r); + } + + private static QueryBuilder b() { + return new QueryBuilder(); + } + + /** + * Builds a {@code SELECT * FROM users WHERE col op val} result using the DB + * dialect. Used for operators that are not directly exposed on {@link QueryBuilder} + * (e.g. {@link Operator#NEQ}, {@link Operator#LT}). + */ + private static SqlResult withCond(String col, Operator op, Object val) { + Query q = new QueryBuilder().from("users").build(); + q.getConditions().add(new ConditionEntry(col, new Condition(op, val), Connector.AND)); + return DB.render(q); + } + + /** + * Builds a {@code SELECT users.name, orders.amount FROM users INNER JOIN orders} result + * using a manually-constructed {@link JoinClause} (plain-table join). + */ + private static SqlResult joinTable() { + Query q = new QueryBuilder().select("users.name", "orders.amount").from("users").build(); + q.getJoins().add( + new JoinClause(JoinClause.Type.INNER, "orders", "users.id = orders.user_id")); + return DB.render(q); + } + + /** Executes {@code r} against {@link #conn} using a bound {@link PreparedStatement}. */ + private static void run(SqlResult r) throws SQLException { + try (PreparedStatement ps = conn.prepareStatement(r.getSql())) { + List params = r.getParameters(); + for (int i = 0; i < params.size(); i++) { + ps.setObject(i + 1, params.get(i)); + } + ps.execute(); + } + } + + /** + * Executes {@code r} inside a savepoint that is always rolled back, preserving + * the seed data for the remaining test cases. + */ + private static void runInTx(SqlResult r) throws SQLException { + Savepoint sp = conn.setSavepoint(); + try { + run(r); + } finally { + conn.rollback(sp); + } + } + + /** Executes raw DDL / DML SQL directly. */ + private static void exec(String sql) throws SQLException { + try (Statement st = conn.createStatement()) { + st.execute(sql); + } + } +} From c60ccea1c679ae68ea39daf96522e0bc3668ce9b Mon Sep 17 00:00:00 2001 From: ez-plugins Date: Sat, 18 Apr 2026 14:22:34 +0200 Subject: [PATCH 4/4] docs: document subquery support in README - Features: add subquery bullet - Quick Start: new Subqueries section with six annotated examples (WHERE IN, EXISTS/NOT EXISTS, scalar EQ, FROM derived-table, JOIN subquery, scalar SELECT subquery) - All Operators table: add whereInSubquery, whereEqualsSubquery, whereExistsSubquery, whereNotExistsSubquery rows; fix whereNotEquals and whereLessThan to correctly show DeleteBuilder-only availability - QueryBuilder Builder Reference: remove non-existent whereLessThan / whereNotEquals, add all seven new subquery helpers with comments - DeleteBuilder reference: add whereInSubquery and whereExistsSubquery --- README.md | 99 ++++++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 95 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index e2a1cb0..919624c 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ A lightweight, fluent Java library for building parameterized SQL queries and fi - Fluent, readable builder API for SELECT, INSERT, UPDATE, DELETE, and CREATE TABLE - All values are parameterized, safe from SQL injection by design - Supports all common operators: `=`, `!=`, `>`, `>=`, `<`, `<=`, `LIKE`, `NOT LIKE`, `IN`, `NOT IN`, `BETWEEN`, `IS NULL`, `IS NOT NULL` +- Subquery support: `WHERE col IN (SELECT ...)`, `WHERE EXISTS (SELECT ...)`, `WHERE NOT EXISTS`, derived-table `FROM (SELECT ...) AS alias`, JOIN subqueries, and scalar `(SELECT ...) AS alias` in SELECT - Column selection, `DISTINCT`, `GROUP BY`, `ORDER BY`, `LIMIT`, and `OFFSET` - SQL dialect support: Standard, MySQL, SQLite - In-memory filtering via `QueryableStorage` @@ -120,6 +121,81 @@ SqlResult result = QueryBuilder.createTable("users") // sql → "CREATE TABLE IF NOT EXISTS users (id INT, name VARCHAR(255), email VARCHAR(255), PRIMARY KEY (id))" ``` +### Subqueries + +Every subquery is a `Query` object built with `QueryBuilder`. Pass it to the relevant builder method — parameters are collected automatically in document order. + +#### WHERE IN subquery + +```java +Query shipped = new QueryBuilder() + .select("user_id").from("orders").whereEquals("status", "shipped").build(); + +SqlResult result = new QueryBuilder() + .from("users").whereInSubquery("id", shipped).buildSql("users"); + +// sql → SELECT * FROM users WHERE id IN (SELECT user_id FROM orders WHERE status = ?) +// params → ["shipped"] +``` + +#### WHERE EXISTS / NOT EXISTS + +```java +Query pending = new QueryBuilder() + .select("id").from("orders").whereEquals("status", "pending").build(); + +new QueryBuilder().from("users").whereExistsSubquery(pending).buildSql("users"); +// → SELECT * FROM users WHERE EXISTS (SELECT id FROM orders WHERE status = ?) + +new QueryBuilder().from("users").whereNotExistsSubquery(pending).buildSql("users"); +// → SELECT * FROM users WHERE NOT EXISTS (SELECT id FROM orders WHERE status = ?) +``` + +#### WHERE col = (scalar subquery) + +```java +Query latestId = new QueryBuilder() + .select("id").from("orders").whereEquals("status", "shipped").limit(1).build(); + +new QueryBuilder().from("users").whereEqualsSubquery("id", latestId).buildSql("users"); +// → SELECT * FROM users WHERE id = (SELECT id FROM orders WHERE status = ? LIMIT 1) +``` + +#### FROM subquery (derived table) + +```java +Query inner = new QueryBuilder().select("id", "name").from("users").build(); + +SqlResult result = new QueryBuilder() + .select("name").fromSubquery(inner, "u").buildSql(null); +// → SELECT name FROM (SELECT id, name FROM users) AS u +``` + +#### JOIN subquery + +```java +Query totals = new QueryBuilder().select("user_id", "amount").from("orders").build(); + +SqlResult result = new QueryBuilder() + .from("users") + .joinSubquery(totals, "o", "users.id = o.user_id") + .buildSql("users"); +// → SELECT * FROM users INNER JOIN (SELECT user_id, amount FROM orders) AS o ON users.id = o.user_id +``` + +#### Scalar SELECT subquery + +```java +Query latest = new QueryBuilder() + .select("amount").from("orders").whereEquals("user_id", 1).limit(1).build(); + +SqlResult result = new QueryBuilder() + .select("id", "name").selectSubquery(latest, "last_order") + .from("users").buildSql("users"); +// → SELECT id, name, (SELECT amount FROM orders WHERE user_id = ? LIMIT 1) AS last_order FROM users +// params → [1] +``` + ### In-Memory Filtering Implement `QueryableStorage` on your storage class to enable query-based lookups without a database: @@ -145,10 +221,10 @@ public class UserStore implements QueryableStorage { |---|---|---| | `whereEquals(col, val)` | `col = ?` | QueryBuilder, DeleteBuilder, UpdateBuilder, SelectBuilder | | `orWhereEquals(col, val)` | `OR col = ?` | QueryBuilder, UpdateBuilder | -| `whereNotEquals(col, val)` | `col != ?` | QueryBuilder, DeleteBuilder | +| `whereNotEquals(col, val)` | `col != ?` | DeleteBuilder | | `whereGreaterThan(col, val)` | `col > ?` | QueryBuilder, DeleteBuilder | | `whereGreaterThanOrEquals(col, val)` | `col >= ?` | QueryBuilder, DeleteBuilder, UpdateBuilder | -| `whereLessThan(col, val)` | `col < ?` | QueryBuilder, DeleteBuilder | +| `whereLessThan(col, val)` | `col < ?` | DeleteBuilder | | `whereLessThanOrEquals(col, val)` | `col <= ?` | QueryBuilder, DeleteBuilder | | `whereLike(col, substr)` | `col LIKE ?` (wrapped with `%`) | QueryBuilder, SelectBuilder | | `whereNotLike(col, substr)` | `col NOT LIKE ?` (wrapped with `%`) | QueryBuilder | @@ -158,6 +234,10 @@ public class UserStore implements QueryableStorage { | `whereIn(col, list)` | `col IN (?, ?, ...)` | QueryBuilder, DeleteBuilder, SelectBuilder | | `whereNotIn(col, list)` | `col NOT IN (?, ?, ...)` | QueryBuilder, DeleteBuilder | | `whereBetween(col, a, b)` | `col BETWEEN ? AND ?` | QueryBuilder, DeleteBuilder | +| `whereInSubquery(col, subquery)` | `col IN (SELECT ...)` | QueryBuilder, DeleteBuilder | +| `whereEqualsSubquery(col, subquery)` | `col = (SELECT ...)` | QueryBuilder | +| `whereExistsSubquery(subquery)` | `EXISTS (SELECT ...)` | QueryBuilder, DeleteBuilder | +| `whereNotExistsSubquery(subquery)` | `NOT EXISTS (SELECT ...)` | QueryBuilder | ## Builder Reference @@ -174,10 +254,8 @@ new QueryBuilder() // --- conditions (all joined with AND unless or* variant used) --- .whereEquals("status", "active") // status = ? .orWhereEquals("status", "pending") // OR status = ? - .whereNotEquals("role", "banned") // role != ? .whereGreaterThan("age", 18) // age > ? .whereGreaterThanOrEquals("age", 18) // age >= ? - .whereLessThan("score", 1000) // score < ? .whereLessThanOrEquals("score", 1000) // score <= ? .whereLike("username", "john") // username LIKE '%john%' .whereNotLike("email", "spam") // email NOT LIKE '%spam%' @@ -188,6 +266,17 @@ new QueryBuilder() .whereNotIn("role", List.of("bot", "banned")) .whereBetween("created_at", from, to) + // --- subquery conditions --- + .whereInSubquery("id", subquery) // id IN (SELECT ...) + .whereEqualsSubquery("id", subquery) // id = (SELECT ...) + .whereExistsSubquery(subquery) // EXISTS (SELECT ...) + .whereNotExistsSubquery(subquery) // NOT EXISTS (SELECT ...) + + // --- subquery FROM / JOIN / SELECT --- + .fromSubquery(subquery, "alias") // FROM (SELECT ...) AS alias + .joinSubquery(subquery, "alias", "t.id = alias.id") // INNER JOIN (SELECT ...) AS alias ON ... + .selectSubquery(subquery, "alias") // (SELECT ...) AS alias appended to SELECT clause + // --- sorting and grouping --- .groupBy("department") .havingRaw("COUNT(*) > 5") @@ -261,6 +350,8 @@ QueryBuilder.deleteFrom("users") .whereIn("status", List.of("expired", "banned")) .whereNotIn("plan", List.of("premium", "trial")) .whereBetween("created_at", from, to) + .whereInSubquery("id", subquery) // id IN (SELECT ...) + .whereExistsSubquery(subquery) // EXISTS (SELECT ...) .build(); // → SqlResult ```