diff --git a/.github/workflows/code-quality.yml b/.github/workflows/code-quality.yml index 78e94f0..719bee8 100644 --- a/.github/workflows/code-quality.yml +++ b/.github/workflows/code-quality.yml @@ -52,11 +52,8 @@ jobs: with: name: jacoco-report path: target/site/jacoco/ - - name: Comment JaCoCo coverage summary in PR - if: github.event_name == 'pull_request' - uses: madrapps/jacoco-report@v1.7.2 + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v5 with: - paths: target/site/jacoco/jacoco.xml - token: ${{ secrets.GITHUB_TOKEN }} - min-coverage-overall: 0 - min-coverage-changed-files: 0 + files: target/site/jacoco/jacoco.xml + token: ${{ secrets.CODECOV_TOKEN }} diff --git a/README.md b/README.md index 637d68d..e2a1cb0 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ Add the dependency to your `pom.xml`: com.github.EzFramework JavaQueryBuilder - 1.0.3 + 1.0.4 ``` @@ -321,14 +321,39 @@ SqlResult result = new QueryBuilder() // → SELECT * FROM "users" WHERE "status" = ? ``` -| Dialect | Identifier quoting | Boolean values | -|---|---|---| -| `SqlDialect.STANDARD` | none | `true` / `false` | -| `SqlDialect.MYSQL` | back-ticks `` `col` `` | `true` / `false` | -| `SqlDialect.SQLITE` | double quotes `"col"` | `1` / `0` | +| Dialect | Identifier quoting | Boolean values | DELETE LIMIT | +|---|---|---|---| +| `SqlDialect.STANDARD` | none | `true` / `false` | no | +| `SqlDialect.MYSQL` | back-ticks `` `col` `` | `true` / `false` | yes | +| `SqlDialect.SQLITE` | double quotes `"col"` | `1` / `0` | yes | MySQL back-tick quoting safely handles reserved words and case-sensitive identifiers. SQLite double-quote quoting serves the same purpose, and Java booleans are converted to `1`/`0` to match SQLite's integer-backed boolean storage. +### Dialect-aware DELETE rendering + +Every `SqlDialect` exposes a `renderDelete(Query query)` method that generates a fully dialect-aware `DELETE FROM ... WHERE ...` statement, identifier quoting and all. MySQL and SQLite also append a `LIMIT` clause when one is set on the query; the standard dialect silently ignores it. + +```java +// Obtain a Query from any QueryBuilder call +Query query = new QueryBuilder() + .from("users") + .whereEquals("id", 42) + .limit(1) + .build(); + +// Standard — no quoting, no LIMIT +SqlDialect.STANDARD.renderDelete(query); +// → DELETE FROM users WHERE id = ? params=[42] + +// MySQL — back-tick quoting + LIMIT +SqlDialect.MYSQL.renderDelete(query); +// → DELETE FROM `users` WHERE `id` = ? LIMIT 1 params=[42] + +// SQLite — double-quote quoting + LIMIT +SqlDialect.SQLITE.renderDelete(query); +// → DELETE FROM "users" WHERE "id" = ? LIMIT 1 params=[42] +``` + ## How SQL Generation Works `buildSql(table)` (or `query.toSql(table)`) translates the `Query` into a single-line parameterized SQL string. All values are returned separately as a `List` — the SQL string itself only contains `?` placeholders, so **user-supplied values are never interpolated into the string**. This makes it inherently safe against SQL injection when used with a `PreparedStatement`. diff --git a/pom.xml b/pom.xml index c4a3df9..e996fbd 100644 --- a/pom.xml +++ b/pom.xml @@ -5,7 +5,7 @@ com.github.EzFramework java-query-builder - 1.0.3 + 1.0.4 jar JavaQueryBuilder 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 04988c4..a1d0f8b 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 @@ -11,10 +11,13 @@ import com.github.ezframework.javaquerybuilder.query.condition.Operator; /** - * Base SQL rendering logic for SELECT queries. + * Base SQL rendering logic for SELECT and DELETE queries. * - *

Implements the standard (ANSI) SQL dialect. Subclasses may override - * {@link #quoteIdentifier(String)} to apply dialect-specific identifier quoting. + *

Implements the standard (ANSI) SQL dialect and provides shared helpers for + * rendering {@code WHERE} clauses. Subclasses may override + * {@link #quoteIdentifier(String)} to apply dialect-specific identifier quoting + * and {@link #supportsDeleteLimit()} to enable dialect-specific DELETE + * {@code LIMIT} behaviour. * * @author EzFramework * @version 1.0.0 @@ -44,6 +47,44 @@ protected String quoteIdentifier(String name) { return name; } + @Override + public SqlResult renderDelete(Query query) { + final StringBuilder sql = new StringBuilder(); + final List params = new ArrayList<>(); + sql.append("DELETE FROM ").append(quoteIdentifier(query.getTable())); + appendWhereClause(sql, params, query); + + if (supportsDeleteLimit() && query.getLimit() != null && query.getLimit() >= 0) { + sql.append(" LIMIT ").append(query.getLimit()); + } + + final String sqlStr = sql.toString(); + final List paramsCopy = Collections.unmodifiableList(new ArrayList<>(params)); + return new SqlResult() { + @Override + public String getSql() { + return sqlStr; + } + + @Override + public List getParameters() { + return paramsCopy; + } + }; + } + + /** + * Hook for dialects that support a `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. + * + * @return {@code true} if the dialect appends a `LIMIT` to DELETE statements + */ + protected boolean supportsDeleteLimit() { + return false; + } + @Override public SqlResult render(Query query) { final StringBuilder sql = new StringBuilder(); @@ -94,7 +135,7 @@ private void appendSelectColumns(StringBuilder sql, Query query) { } } - private void appendWhereClause(StringBuilder sql, List params, Query query) { + protected void appendWhereClause(StringBuilder sql, List params, Query query) { final List conditions = query.getConditions(); if (conditions.isEmpty()) { return; @@ -111,7 +152,7 @@ private void appendWhereClause(StringBuilder sql, List params, Query que } @SuppressWarnings("unchecked") - private void appendConditionFragment(StringBuilder sql, List params, ConditionEntry entry) { + protected void appendConditionFragment(StringBuilder sql, List params, ConditionEntry entry) { final Operator op = entry.getCondition().getOperator(); if (COMPARISON_OPERATORS.containsKey(op)) { sql.append(COMPARISON_OPERATORS.get(op)); diff --git a/src/main/java/com/github/ezframework/javaquerybuilder/query/sql/SqlDialect.java b/src/main/java/com/github/ezframework/javaquerybuilder/query/sql/SqlDialect.java index 5d93508..d0929af 100644 --- a/src/main/java/com/github/ezframework/javaquerybuilder/query/sql/SqlDialect.java +++ b/src/main/java/com/github/ezframework/javaquerybuilder/query/sql/SqlDialect.java @@ -33,4 +33,17 @@ public interface SqlDialect { * @return the SQL result containing the SQL string and bound parameters */ SqlResult render(Query query); + + /** + * Renders a DELETE statement for the given query (DELETE FROM ... WHERE ...). + * + * @param query the query describing the delete target and conditions + * @return the SQL result containing the SQL string and bound parameters + * + *

Default behaviour is implemented by {@link AbstractSqlDialect#renderDelete(Query)}, + * which renders {@code DELETE FROM WHERE ...} and preserves parameter + * ordering to match {@link #render(Query)}. Dialects that support a DELETE + * {@code LIMIT} may enable it by overriding {@link AbstractSqlDialect#supportsDeleteLimit()}. + */ + SqlResult renderDelete(Query query); } diff --git a/src/main/java/com/github/ezframework/javaquerybuilder/query/sql/mysql/MySqlDialect.java b/src/main/java/com/github/ezframework/javaquerybuilder/query/sql/mysql/MySqlDialect.java index 1dea4c1..b710bd0 100644 --- a/src/main/java/com/github/ezframework/javaquerybuilder/query/sql/mysql/MySqlDialect.java +++ b/src/main/java/com/github/ezframework/javaquerybuilder/query/sql/mysql/MySqlDialect.java @@ -17,4 +17,9 @@ public class MySqlDialect extends AbstractSqlDialect { protected String quoteIdentifier(String name) { return "`" + name + "`"; } + + @Override + protected boolean supportsDeleteLimit() { + return true; + } } diff --git a/src/main/java/com/github/ezframework/javaquerybuilder/query/sql/sqlite/SqliteDialect.java b/src/main/java/com/github/ezframework/javaquerybuilder/query/sql/sqlite/SqliteDialect.java index ca37efb..ce729eb 100644 --- a/src/main/java/com/github/ezframework/javaquerybuilder/query/sql/sqlite/SqliteDialect.java +++ b/src/main/java/com/github/ezframework/javaquerybuilder/query/sql/sqlite/SqliteDialect.java @@ -17,4 +17,9 @@ public class SqliteDialect extends AbstractSqlDialect { protected String quoteIdentifier(String name) { return "\"" + name + "\""; } + + @Override + protected boolean supportsDeleteLimit() { + return true; + } } diff --git a/src/test/java/com/github/ezframework/javaquerybuilder/query/sql/AbstractSqlDialectDeleteTest.java b/src/test/java/com/github/ezframework/javaquerybuilder/query/sql/AbstractSqlDialectDeleteTest.java new file mode 100644 index 0000000..6139185 --- /dev/null +++ b/src/test/java/com/github/ezframework/javaquerybuilder/query/sql/AbstractSqlDialectDeleteTest.java @@ -0,0 +1,89 @@ +package com.github.ezframework.javaquerybuilder.query.sql; + +import java.util.List; + +import com.github.ezframework.javaquerybuilder.query.builder.QueryBuilder; +import com.github.ezframework.javaquerybuilder.query.Query; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +public class AbstractSqlDialectDeleteTest { + + static class DummyDialect extends AbstractSqlDialect {} + + @Test + void deleteParametersMatchSelectParameters() { + final DummyDialect dialect = new DummyDialect(); + final com.github.ezframework.javaquerybuilder.query.condition.ConditionEntry ae = + new com.github.ezframework.javaquerybuilder.query.condition.ConditionEntry("a", + new com.github.ezframework.javaquerybuilder.query.condition.Condition( + com.github.ezframework.javaquerybuilder.query.condition.Operator.EQ, 1), + com.github.ezframework.javaquerybuilder.query.condition.Connector.AND); + + final com.github.ezframework.javaquerybuilder.query.condition.ConditionEntry ce = + new com.github.ezframework.javaquerybuilder.query.condition.ConditionEntry("c", + new com.github.ezframework.javaquerybuilder.query.condition.Condition( + com.github.ezframework.javaquerybuilder.query.condition.Operator.GT, 2), + com.github.ezframework.javaquerybuilder.query.condition.Connector.AND); + + final com.github.ezframework.javaquerybuilder.query.condition.ConditionEntry inE = + new com.github.ezframework.javaquerybuilder.query.condition.ConditionEntry("l", + new com.github.ezframework.javaquerybuilder.query.condition.Condition( + com.github.ezframework.javaquerybuilder.query.condition.Operator.IN, List.of(6, 7)), + com.github.ezframework.javaquerybuilder.query.condition.Connector.AND); + + final com.github.ezframework.javaquerybuilder.query.condition.ConditionEntry betweenE = + new com.github.ezframework.javaquerybuilder.query.condition.ConditionEntry("n", + new com.github.ezframework.javaquerybuilder.query.condition.Condition( + com.github.ezframework.javaquerybuilder.query.condition.Operator.BETWEEN, List.of(10, 11)), + com.github.ezframework.javaquerybuilder.query.condition.Connector.AND); + + final Query q = new Query(); + q.setTable("t"); + q.setConditions(List.of(ae, ce, inE, betweenE)); + + final SqlResult select = dialect.render(q); + final SqlResult del = dialect.renderDelete(q); + + assertTrue(del.getSql().startsWith("DELETE FROM t")); + assertTrue(del.getSql().contains("WHERE")); + assertTrue(del.getSql().contains("?")); + assertEquals(select.getParameters(), del.getParameters()); + } + + @Test + void sqliteAndMysqlApplyQuotingAndLimitBehavior() { + final QueryBuilder qb = new QueryBuilder().from("users").whereEquals("id", 42).limit(5); + final Query q = qb.build(); + q.setTable("users"); + final com.github.ezframework.javaquerybuilder.query.sql.mysql.MySqlDialect my = + new com.github.ezframework.javaquerybuilder.query.sql.mysql.MySqlDialect(); + final SqlResult myDel = my.renderDelete(q); + assertTrue(myDel.getSql().startsWith("DELETE FROM `users`")); + assertTrue(myDel.getSql().contains("LIMIT 5")); + + final SqlDialect sqlite = new com.github.ezframework.javaquerybuilder.query.sql.sqlite.SqliteDialect(); + final SqlResult sqDel = sqlite.renderDelete(q); + assertTrue(sqDel.getSql().startsWith("DELETE FROM \"users\"")); + assertTrue(sqDel.getSql().contains("LIMIT 5")); + + final DummyDialect d = new DummyDialect(); + final SqlResult dDel = d.renderDelete(q); + assertTrue(dDel.getSql().startsWith("DELETE FROM users")); + assertFalse(dDel.getSql().contains("LIMIT")); + } + + @Test + void inClauseGeneratesCorrectPlaceholderCountAndOrdering() { + final DummyDialect dialect = new DummyDialect(); + final QueryBuilder qb = new QueryBuilder().from("items").whereIn("id", List.of(1, 2, 3)); + final Query q = qb.build(); + q.setTable("items"); + + final SqlResult del = dialect.renderDelete(q); + assertEquals("DELETE FROM items WHERE id IN (?, ?, ?)", del.getSql()); + assertEquals(List.of(1, 2, 3), del.getParameters()); + } +}