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/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;
}
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:
+ *
+ * SELECT-list scalar subquery parameters (left to right)
+ * FROM subquery parameters
+ * JOIN subquery parameters (left to right)
+ * WHERE condition subquery parameters (top to bottom)
+ *
+ *
* @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;
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);
+ }
+ }
+}