Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 95 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down Expand Up @@ -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:
Expand All @@ -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 |
Expand All @@ -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

Expand All @@ -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%'
Expand All @@ -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")
Expand Down Expand Up @@ -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
```

Expand Down
12 changes: 12 additions & 0 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,18 @@
<version>6.0.3</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-params</artifactId>
<version>6.0.3</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.xerial</groupId>
<artifactId>sqlite-jdbc</artifactId>
<version>3.46.1.3</version>
<scope>test</scope>
</dependency>
</dependencies>

<build>
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
*
* <p>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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<JoinClause> joins = new ArrayList<>();

/** The list of scalar subquery items appended to the SELECT clause. */
private List<ScalarSelectItem> selectSubqueries = new ArrayList<>();

/**
* Gets the source table for the query.
*
Expand Down Expand Up @@ -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<JoinClause> getJoins() {
return joins;
}

/**
* Sets the list of JOIN clauses.
*
* @param joins the list of JOIN clauses
*/
public void setJoins(List<JoinClause> 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<ScalarSelectItem> 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<ScalarSelectItem> selectSubqueries) {
this.selectSubqueries = selectSubqueries;
}
}
Loading
Loading