diff --git a/AGENTS.md b/AGENTS.md index 80a9c8e..e38704a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -80,6 +80,39 @@ open target/site/jacoco/index.html - Exception hierarchy: `QueryBuilderException` → `QueryException` / `QueryRenderException` in `query.exception` - Test class names match source class names with `Test` suffix and live in the same sub-package under `src/test/` +## Agent Planning + +For multi-step tasks the agent maintains a visible todo list to track progress. Each item moves through three states: `not-started` → `in-progress` → `completed`. Only one item is in-progress at a time; it is marked completed immediately when done. + +**Typical workflow for a non-trivial request**: + +1. **Gather context** — read relevant source files and tests in parallel. +2. **Plan** — break the work into concrete, ordered todos (e.g., add operator → update builder → write tests → verify build). +3. **Execute** — work through todos one at a time, updating state as each finishes. +4. **Validate** — run `mvn checkstyle:check` and `mvn test` after changes; fix any failures before marking the task complete. + +**When planning is skipped**: trivial, single-step requests (e.g., "what does `Operator.EQ` do?") do not need a todo list. + +**Communicating with the agent**: if you want the agent to change direction mid-task, just say so — it will update the plan and continue from the new state. + +## Agent Memory + +Copilot agents can persist project-scoped notes in `/memories/repo/` to carry context across sessions. Use this to record verified facts about the codebase so they don't need to be re-discovered. + +**Create a repo memory note** (only `create` is supported for this path): + +> "Remember that the `Query` class is immutable and built exclusively via the builder classes." + +The agent will store this under `/memories/repo/` and load it automatically in future sessions. + +**Useful things to store**: +- Verified build commands and their quirks +- Project-specific conventions not obvious from the code +- Known gotchas (e.g., "always run `mvn checkstyle:check` before committing") +- Architecture decisions and their rationale + +**Scope**: repo memory is local to this workspace and is never shared or published. + ## Packaging and Distribution - **GitHub Packages**: published automatically on GitHub Release creation via [.github/workflows/publish.yml](.github/workflows/publish.yml) diff --git a/src/main/java/com/github/ezframework/javaquerybuilder/query/builder/CreateBuilder.java b/src/main/java/com/github/ezframework/javaquerybuilder/query/builder/CreateBuilder.java new file mode 100644 index 0000000..b3c1839 --- /dev/null +++ b/src/main/java/com/github/ezframework/javaquerybuilder/query/builder/CreateBuilder.java @@ -0,0 +1,125 @@ +package com.github.ezframework.javaquerybuilder.query.builder; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import com.github.ezframework.javaquerybuilder.query.sql.SqlDialect; +import com.github.ezframework.javaquerybuilder.query.sql.SqlResult; + +/** + * Builder for SQL CREATE TABLE statements. + * + * @author EzFramework + * @version 1.0.0 + */ +public class CreateBuilder { + + /** The table to create. */ + private String table; + + /** The columns and their SQL types. */ + private final Map columns = new LinkedHashMap<>(); + + /** The primary key columns. */ + private final List primaryKeys = new ArrayList<>(); + + /** Whether to add IF NOT EXISTS. */ + private boolean ifNotExists = false; + + /** + * Sets the table to create. + * @param table the table name + * @return this builder + */ + public CreateBuilder table(String table) { + this.table = table; + return this; + } + + /** + * Adds a column definition. + * @param name the column name + * @param sqlType the SQL type (e.g. "VARCHAR(255)", "INT") + * @return this builder + */ + public CreateBuilder column(String name, String sqlType) { + columns.put(name, sqlType); + return this; + } + + /** + * Adds a primary key column. + * @param name the column name + * @return this builder + */ + public CreateBuilder primaryKey(String name) { + primaryKeys.add(name); + return this; + } + + /** + * Adds IF NOT EXISTS to the statement. + * @return this builder + */ + public CreateBuilder ifNotExists() { + this.ifNotExists = true; + return this; + } + + /** + * Builds the SQL CREATE TABLE statement. + * + * @return the SQL result + * @throws IllegalStateException if table name or columns are missing + */ + public SqlResult build() { + return build(null); + } + + /** + * Builds the SQL CREATE TABLE statement using the given dialect. + * + * @param dialect the SQL dialect to use; if {@code null}, the standard dialect is used + * @return the SQL result + * @throws IllegalStateException if table name or columns are missing + */ + public SqlResult build(SqlDialect dialect) { + if (table == null || columns.isEmpty()) { + throw new IllegalStateException("Table name and at least one column are required"); + } + final StringBuilder sql = new StringBuilder(); + sql.append("CREATE TABLE "); + if (ifNotExists) { + sql.append("IF NOT EXISTS "); + } + sql.append(table).append(" ("); + boolean first = true; + for (Map.Entry entry : columns.entrySet()) { + if (!first) { + sql.append(", "); + } + sql.append(entry.getKey()).append(" ").append(entry.getValue()); + first = false; + } + if (!primaryKeys.isEmpty()) { + sql.append(", PRIMARY KEY ("); + sql.append(String.join(", ", primaryKeys)); + sql.append(")"); + } + sql.append(")"); + + return new SqlResult() { + @Override + public String getSql() { + return sql.toString(); + } + + @Override + public List getParameters() { + return List.of(); + } + }; + } +} 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 707bacc..cc20cc7 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,6 +2,7 @@ import java.util.ArrayList; import java.util.List; +import java.util.Map; import com.github.ezframework.javaquerybuilder.query.condition.Condition; import com.github.ezframework.javaquerybuilder.query.condition.ConditionEntry; @@ -18,6 +19,16 @@ */ 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; @@ -60,49 +71,119 @@ public DeleteBuilder whereLessThan(String column, Object value) { return this; } + /** + * Adds an IN WHERE condition. + * @param column the column name + * @param values the collection of values for the IN clause + * @return this builder + * @throws IllegalArgumentException if values is null or empty + */ + public DeleteBuilder whereIn(String column, List values) { + if (values == null || values.isEmpty()) { + throw new IllegalArgumentException("IN value list must not be null or empty"); + } + conditions.add(new ConditionEntry(column, new Condition(Operator.IN, values), Connector.AND)); + return this; + } + + /** + * Adds a NOT IN WHERE condition. + * @param column the column name + * @param values the collection of values for the NOT IN clause + * @return this builder + * @throws IllegalArgumentException if values is null or empty + */ + public DeleteBuilder whereNotIn(String column, List values) { + if (values == null || values.isEmpty()) { + throw new IllegalArgumentException("NOT IN value list must not be null or empty"); + } + conditions.add(new ConditionEntry(column, new Condition(Operator.NOT_IN, values), Connector.AND)); + return this; + } + + /** + * Adds a BETWEEN WHERE condition. + * @param column the column name + * @param from the lower bound value (inclusive) + * @param to the upper bound value (inclusive) + * @return this builder + */ + public DeleteBuilder whereBetween(String column, Object from, Object to) { + conditions.add(new ConditionEntry(column, new Condition(Operator.BETWEEN, List.of(from, to)), Connector.AND)); + return this; + } + + /** + * Adds a greater-than WHERE condition. + * @param column the column name + * @param value the value to compare + * @return this builder + */ + public DeleteBuilder whereGreaterThan(String column, Object value) { + conditions.add(new ConditionEntry(column, new Condition(Operator.GT, value), Connector.AND)); + return this; + } + + /** + * Adds a greater-than-or-equals WHERE condition. + * @param column the column name + * @param value the value to compare + * @return this builder + */ + public DeleteBuilder whereGreaterThanOrEquals(String column, Object value) { + conditions.add(new ConditionEntry(column, new Condition(Operator.GTE, value), Connector.AND)); + return this; + } + + /** + * Adds a less-than-or-equals WHERE condition. + * @param column the column name + * @param value the value to compare + * @return this builder + */ + public DeleteBuilder whereLessThanOrEquals(String column, Object value) { + conditions.add(new ConditionEntry(column, new Condition(Operator.LTE, value), Connector.AND)); + return this; + } + + /** + * Adds a not-equals WHERE condition. + * @param column the column name + * @param value the value to compare + * @return this builder + */ + public DeleteBuilder whereNotEquals(String column, Object value) { + conditions.add(new ConditionEntry(column, new Condition(Operator.NEQ, value), Connector.AND)); + return this; + } + /** * Builds the SQL DELETE statement. * * @return the SQL result - * @throws UnsupportedOperationException if the operation is not supported */ public SqlResult build() { return build(null); } /** - * Builds the SQL DELETE statement with a dialect. + * Builds the SQL DELETE statement using the given dialect. * - * @param dialect the SQL dialect + * @param dialect the SQL dialect (may be null for standard SQL) * @return the SQL result - * @throws UnsupportedOperationException if the operator is not supported */ - public SqlResult build(SqlDialect dialect) { + 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 i = 0; i < conditions.size(); i++) { - final ConditionEntry cond = conditions.get(i); - if (i > 0) { + for (int j = 0; j < conditions.size(); j++) { + final ConditionEntry cond = conditions.get(j); + if (j > 0) { sql.append(" ").append(cond.getConnector().name()).append(" "); } - final Operator op = cond.getCondition().getOperator(); - sql.append(cond.getColumn()); - switch (op) { - case EQ: - sql.append(" = ?"); - params.add(cond.getCondition().getValue()); - break; - case LT: - sql.append(" < ?"); - params.add(cond.getCondition().getValue()); - break; - default: - throw new UnsupportedOperationException("Operator not supported in DeleteBuilder: " + op); - } + appendDmlCondition(sql, params, cond); } } return new SqlResult() { @@ -117,4 +198,92 @@ public List getParameters() { } }; } + + 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()); + } + } + + /** + * Handles the NOT IN operator for SQL generation. + * + * @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 + */ + 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(")"); + } + + /** + * Handles the BETWEEN operator for SQL generation. + * + * @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 + */ + 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)); + } + /** + * 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(")"); + } } 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 28b31b6..d3c3423 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 @@ -12,6 +12,30 @@ import com.github.ezframework.javaquerybuilder.query.sql.SqlDialect; import com.github.ezframework.javaquerybuilder.query.sql.SqlResult; +/** + * Fluent builder for SQL SELECT queries and gateway to all other DML/DDL builders. + * + *

Use the static factory methods to obtain a builder for any SQL statement type: + *

+ *   // SELECT
+ *   QueryBuilder.from("users").whereEquals("id", 1).buildSql();
+ *
+ *   // INSERT
+ *   QueryBuilder.insertInto("users").value("name", "Alice").build();
+ *
+ *   // UPDATE
+ *   QueryBuilder.update("users").set("name", "Bob").whereEquals("id", 1).build();
+ *
+ *   // DELETE
+ *   QueryBuilder.deleteFrom("users").whereEquals("id", 1).build();
+ *
+ *   // CREATE TABLE
+ *   QueryBuilder.createTable("users").column("id", "INT").primaryKey("id").build();
+ * 
+ * + * @author EzFramework + * @version 1.0.0 + */ public class QueryBuilder { /** The columns to select. */ @@ -44,6 +68,82 @@ public class QueryBuilder { /** The source table for SELECT. */ private String table = null; + /** + * Returns a new {@link InsertBuilder}. + * + * @return a new InsertBuilder + */ + public static InsertBuilder insert() { + return new InsertBuilder(); + } + + /** + * Returns a new {@link InsertBuilder} pre-configured for the given table. + * + * @param table the table to insert into + * @return a new InsertBuilder targeting {@code table} + */ + public static InsertBuilder insertInto(String table) { + return new InsertBuilder().into(table); + } + + /** + * Returns a new {@link UpdateBuilder}. + * + * @return a new UpdateBuilder + */ + public static UpdateBuilder update() { + return new UpdateBuilder(); + } + + /** + * Returns a new {@link UpdateBuilder} pre-configured for the given table. + * + * @param table the table to update + * @return a new UpdateBuilder targeting {@code table} + */ + public static UpdateBuilder update(String table) { + return new UpdateBuilder().table(table); + } + + /** + * Returns a new {@link DeleteBuilder}. + * + * @return a new DeleteBuilder + */ + public static DeleteBuilder delete() { + return new DeleteBuilder(); + } + + /** + * Returns a new {@link DeleteBuilder} pre-configured for the given table. + * + * @param table the table to delete from + * @return a new DeleteBuilder targeting {@code table} + */ + public static DeleteBuilder deleteFrom(String table) { + return new DeleteBuilder().from(table); + } + + /** + * Returns a new {@link CreateBuilder} for DDL CREATE TABLE statements. + * + * @return a new CreateBuilder + */ + public static CreateBuilder createTable() { + return new CreateBuilder(); + } + + /** + * Returns a new {@link CreateBuilder} pre-configured for the given table name. + * + * @param table the table to create + * @return a new CreateBuilder targeting {@code table} + */ + public static CreateBuilder createTable(String table) { + return new CreateBuilder().table(table); + } + /** * Sets the source table for the SELECT query. * @@ -55,16 +155,34 @@ public QueryBuilder from(String fromTable) { return this; } + /** + * Specifies the columns to include in the SELECT clause. + * + * @param columns one or more column names to select + * @return this builder instance for chaining + */ public QueryBuilder select(String... columns) { selectColumns.addAll(Arrays.asList(columns)); return this; } + /** + * Adds {@code DISTINCT} to the SELECT clause. + * + * @return this builder instance for chaining + */ public QueryBuilder distinct() { isDistinct = true; return this; } + /** + * Adds an equality ({@code =}) WHERE condition joined with AND. + * + * @param column the column name + * @param value the value to compare against + * @return this builder instance for chaining + */ public QueryBuilder whereEquals(String column, Object value) { conditions.add( new ConditionEntry( @@ -76,6 +194,13 @@ public QueryBuilder whereEquals(String column, Object value) { return this; } + /** + * Adds an equality ({@code =}) WHERE condition joined with OR. + * + * @param column the column name + * @param value the value to compare against + * @return this builder instance for chaining + */ public QueryBuilder orWhereEquals(String column, Object value) { conditions.add( new ConditionEntry( @@ -87,6 +212,13 @@ public QueryBuilder orWhereEquals(String column, Object value) { return this; } + /** + * Adds a {@code LIKE} WHERE condition joined with AND. + * + * @param column the column name + * @param value the LIKE pattern + * @return this builder instance for chaining + */ public QueryBuilder whereLike(String column, String value) { conditions.add( new ConditionEntry( @@ -98,6 +230,13 @@ public QueryBuilder whereLike(String column, String value) { return this; } + /** + * Adds a {@code NOT LIKE} WHERE condition joined with AND. + * + * @param column the column name + * @param value the LIKE pattern + * @return this builder instance for chaining + */ public QueryBuilder whereNotLike(String column, String value) { conditions.add( new ConditionEntry( @@ -109,6 +248,12 @@ public QueryBuilder whereNotLike(String column, String value) { return this; } + /** + * Adds an {@code EXISTS} WHERE condition joined with AND. + * + * @param column the column name + * @return this builder instance for chaining + */ public QueryBuilder whereExists(String column) { conditions.add( new ConditionEntry( @@ -120,6 +265,12 @@ public QueryBuilder whereExists(String column) { return this; } + /** + * Adds an {@code IS NULL} WHERE condition joined with AND. + * + * @param column the column name + * @return this builder instance for chaining + */ public QueryBuilder whereNull(String column) { conditions.add( new ConditionEntry( @@ -131,6 +282,12 @@ public QueryBuilder whereNull(String column) { return this; } + /** + * Adds an {@code IS NOT NULL} WHERE condition joined with AND. + * + * @param column the column name + * @return this builder instance for chaining + */ public QueryBuilder whereNotNull(String column) { conditions.add( new ConditionEntry( @@ -142,6 +299,13 @@ public QueryBuilder whereNotNull(String column) { return this; } + /** + * Adds an {@code IN} WHERE condition joined with AND. + * + * @param column the column name + * @param values the list of values + * @return this builder instance for chaining + */ public QueryBuilder whereIn(String column, List values) { conditions.add( new ConditionEntry( @@ -153,6 +317,13 @@ public QueryBuilder whereIn(String column, List values) { return this; } + /** + * Adds a {@code NOT IN} WHERE condition joined with AND. + * + * @param column the column name + * @param values the list of values + * @return this builder instance for chaining + */ public QueryBuilder whereNotIn(String column, List values) { conditions.add( new ConditionEntry( @@ -164,6 +335,14 @@ public QueryBuilder whereNotIn(String column, List values) { return this; } + /** + * Adds a {@code BETWEEN} WHERE condition joined with AND. + * + * @param column the column name + * @param a the lower bound (inclusive) + * @param b the upper bound (inclusive) + * @return this builder instance for chaining + */ public QueryBuilder whereBetween(String column, Object a, Object b) { conditions.add( new ConditionEntry( @@ -175,6 +354,13 @@ public QueryBuilder whereBetween(String column, Object a, Object b) { return this; } + /** + * Adds a greater-than ({@code >}) WHERE condition joined with AND. + * + * @param column the column name + * @param value the value to compare against + * @return this builder instance for chaining + */ public QueryBuilder whereGreaterThan(String column, Object value) { conditions.add( new ConditionEntry( @@ -186,6 +372,13 @@ public QueryBuilder whereGreaterThan(String column, Object value) { return this; } + /** + * Adds a greater-than-or-equal ({@code >=}) WHERE condition joined with AND. + * + * @param column the column name + * @param value the value to compare against + * @return this builder instance for chaining + */ public QueryBuilder whereGreaterThanOrEquals(String column, Object value) { conditions.add( new ConditionEntry( @@ -197,6 +390,13 @@ public QueryBuilder whereGreaterThanOrEquals(String column, Object value) { return this; } + /** + * Adds a less-than-or-equal ({@code <=}) WHERE condition joined with AND. + * + * @param column the column name + * @param value the value to compare against + * @return this builder instance for chaining + */ public QueryBuilder whereLessThanOrEquals(String column, Object value) { conditions.add( new ConditionEntry( @@ -208,32 +408,68 @@ public QueryBuilder whereLessThanOrEquals(String column, Object value) { return this; } + /** + * Adds one or more columns to the GROUP BY clause. + * + * @param columns the column names + * @return this builder instance for chaining + */ public QueryBuilder groupBy(String... columns) { groupByColumns.addAll(Arrays.asList(columns)); return this; } + /** + * Adds a column to the ORDER BY clause. + * + * @param column the column name + * @param asc {@code true} for ascending, {@code false} for descending + * @return this builder instance for chaining + */ public QueryBuilder orderBy(String column, boolean asc) { orderByColumns.add(column); orderByAsc.add(asc); return this; } + /** + * Sets the row limit. + * + * @param limit the maximum number of rows to return + * @return this builder instance for chaining + */ public QueryBuilder limit(int limit) { this.limit = limit; return this; } + /** + * Sets the row offset. + * + * @param offset the number of rows to skip + * @return this builder instance for chaining + */ public QueryBuilder offset(int offset) { this.offset = offset; return this; } + /** + * Sets a raw HAVING clause. + * + * @param clause the raw SQL HAVING clause (e.g. {@code "COUNT(*) > 5"}) + * @return this builder instance for chaining + */ public QueryBuilder havingRaw(String clause) { this.havingRaw = clause; return this; } + /** + * Builds a {@link Query} object from the current builder state. + * + * @return an immutable {@link Query} representing the SELECT statement + */ public Query build() { final Query q = new Query(); q.setLimit(limit); diff --git a/src/main/java/com/github/ezframework/javaquerybuilder/query/builder/SelectBuilder.java b/src/main/java/com/github/ezframework/javaquerybuilder/query/builder/SelectBuilder.java new file mode 100644 index 0000000..5a9304b --- /dev/null +++ b/src/main/java/com/github/ezframework/javaquerybuilder/query/builder/SelectBuilder.java @@ -0,0 +1,298 @@ +package com.github.ezframework.javaquerybuilder.query.builder; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +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; + +/** + * Builder for SQL SELECT statements. + * + * @author EzFramework + * @version 1.0.0 + */ +public class SelectBuilder { + /** + * The table to select from. + */ + private String table; + + /** + * The columns to select. + */ + private final List columns = new ArrayList<>(); + + /** + * The WHERE conditions. + */ + private final List conditions = new ArrayList<>(); + + /** + * The GROUP BY columns. + */ + private final List groupBy = new ArrayList<>(); + + /** + * The ORDER BY columns. + */ + private final List orderBy = new ArrayList<>(); + + /** + * The ORDER BY directions (true for ASC, false for DESC). + */ + private final List orderByAsc = new ArrayList<>(); + + /** + * The LIMIT value. + */ + private int limit = -1; + + /** + * The OFFSET value. + */ + private int offset = -1; + + /** + * Whether to use DISTINCT. + */ + private boolean distinct = false; + + /** + * Sets the table to select from. + * @param table the table name + * @return this builder + */ + public SelectBuilder from(String table) { + this.table = table; + return this; + } + + /** + * Adds columns to select. If none are added, defaults to *. + * @param columns the column names + * @return this builder + */ + public SelectBuilder select(String... columns) { + this.columns.addAll(Arrays.asList(columns)); + return this; + } + + /** + * Sets DISTINCT. + * @return this builder + */ + public SelectBuilder distinct() { + this.distinct = true; + return this; + } + + /** + * Adds a WHERE condition. + * @param column the column name + * @param value the value to compare + * @return this builder + */ + public SelectBuilder whereEquals(String column, Object value) { + conditions.add(new ConditionEntry( + column, + new Condition(Operator.EQ, value), + conditions.isEmpty() ? Connector.AND : Connector.AND)); + return this; + } + + /** + * Adds a WHERE IN condition. + * @param column the column name + * @param values the values for IN + * @return this builder + */ + public SelectBuilder whereIn(String column, List values) { + conditions.add(new ConditionEntry( + column, + new Condition(Operator.IN, values), + conditions.isEmpty() ? Connector.AND : Connector.AND)); + return this; + } + + /** + * Adds a WHERE LIKE condition. + * @param column the column name + * @param value the value for LIKE + * @return this builder + */ + public SelectBuilder whereLike(String column, String value) { + conditions.add(new ConditionEntry( + column, + new Condition(Operator.LIKE, value), + conditions.isEmpty() ? Connector.AND : Connector.AND)); + return this; + } + + /** + * Adds a GROUP BY clause. + * @param columns the columns to group by + * @return this builder + */ + public SelectBuilder groupBy(String... columns) { + groupBy.addAll(Arrays.asList(columns)); + return this; + } + + /** + * Adds an ORDER BY clause. + * @param column the column to order by + * @param asc true for ASC, false for DESC + * @return this builder + */ + public SelectBuilder orderBy(String column, boolean asc) { + orderBy.add(column); + orderByAsc.add(asc); + return this; + } + + /** + * Sets the LIMIT. + * @param limit the limit + * @return this builder + */ + public SelectBuilder limit(int limit) { + this.limit = limit; + return this; + } + + /** + * Sets the OFFSET. + * @param offset the offset + * @return this builder + */ + public SelectBuilder offset(int offset) { + this.offset = offset; + return this; + } + + /** + * Builds the SQL SELECT statement. + * @return the SQL result + * @throws IllegalStateException if table is not set + */ + public SqlResult build() { + return build(null); + } + + /** + * Builds the SQL SELECT statement with a dialect. + * @param dialect the SQL dialect (optional) + * @return the SQL result + * @throws IllegalStateException if table is not set + * @throws UnsupportedOperationException if an unsupported operator is used + */ + public SqlResult build(SqlDialect dialect) { + if (table == null) { + throw new IllegalStateException("Table name is required"); + } + final StringBuilder sql = new StringBuilder(); + final List params = new ArrayList<>(); + buildSelectClause(sql); + buildFromClause(sql); + buildWhereClause(sql, params); + buildGroupByClause(sql); + buildOrderByClause(sql); + buildLimitOffsetClause(sql); + return new SqlResult() { + @Override + public String getSql() { + return sql.toString(); + } + + @Override + public List getParameters() { + return params; + } + }; + } + + private void buildSelectClause(final StringBuilder sql) { + sql.append("SELECT "); + if (distinct) { + sql.append("DISTINCT "); + } + if (columns.isEmpty()) { + sql.append("*"); + } else { + sql.append(String.join(", ", columns)); + } + } + + private void buildFromClause(final StringBuilder sql) { + sql.append(" FROM ").append(table); + } + + private void buildWhereClause(final StringBuilder sql, final List params) { + if (conditions.isEmpty()) { + return; + } + sql.append(" WHERE "); + for (int i = 0; i < conditions.size(); i++) { + if (i > 0) { + sql.append(" AND "); + } + final ConditionEntry cond = conditions.get(i); + sql.append(cond.getColumn()).append(" "); + final Operator op = cond.getCondition().getOperator(); + if (op == Operator.EQ) { + sql.append("= ?"); + params.add(cond.getCondition().getValue()); + } else if (op == Operator.IN) { + final List inVals = (List) cond.getCondition().getValue(); + sql.append("IN ("); + for (int j = 0; j < inVals.size(); j++) { + if (j > 0) { + sql.append(", "); + } + sql.append("?"); + params.add(inVals.get(j)); + } + sql.append(")"); + } else if (op == Operator.LIKE) { + sql.append("LIKE ?"); + params.add(cond.getCondition().getValue()); + } else { + throw new UnsupportedOperationException( + "Operator not supported: " + cond.getCondition().getOperator()); + } + } + } + + private void buildGroupByClause(final StringBuilder sql) { + if (!groupBy.isEmpty()) { + sql.append(" GROUP BY ").append(String.join(", ", groupBy)); + } + } + + private void buildOrderByClause(final StringBuilder sql) { + if (!orderBy.isEmpty()) { + sql.append(" ORDER BY "); + for (int i = 0; i < orderBy.size(); i++) { + if (i > 0) { + sql.append(", "); + } + sql.append(orderBy.get(i)).append(orderByAsc.get(i) ? " ASC" : " DESC"); + } + } + } + + private void buildLimitOffsetClause(final StringBuilder sql) { + if (limit >= 0) { + sql.append(" LIMIT ").append(limit); + } + if (offset >= 0) { + sql.append(" OFFSET ").append(offset); + } + } +} diff --git a/src/test/java/com/github/ezframework/javaquerybuilder/query/builder/CreateBuilderTest.java b/src/test/java/com/github/ezframework/javaquerybuilder/query/builder/CreateBuilderTest.java new file mode 100644 index 0000000..f0f3845 --- /dev/null +++ b/src/test/java/com/github/ezframework/javaquerybuilder/query/builder/CreateBuilderTest.java @@ -0,0 +1,91 @@ +package com.github.ezframework.javaquerybuilder.query.builder; + +import org.junit.jupiter.api.Test; + +import com.github.ezframework.javaquerybuilder.query.sql.SqlDialect; +import com.github.ezframework.javaquerybuilder.query.sql.SqlResult; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +public class CreateBuilderTest { + + @Test + void buildsBasicCreateTable() { + SqlResult result = new CreateBuilder() + .table("users") + .column("id", "INT") + .column("name", "VARCHAR(255)") + .primaryKey("id") + .build(); + assertEquals("CREATE TABLE users (id INT, name VARCHAR(255), PRIMARY KEY (id))", result.getSql()); + assertTrue(result.getParameters().isEmpty()); + } + + @Test + void buildsWithIfNotExists() { + SqlResult result = new CreateBuilder() + .table("items") + .column("id", "INT") + .ifNotExists() + .build(); + assertEquals("CREATE TABLE IF NOT EXISTS items (id INT)", result.getSql()); + } + + @Test + void throwsIfNoTableOrColumns() { + assertThrows(IllegalStateException.class, () -> new CreateBuilder().build()); + assertThrows(IllegalStateException.class, () -> new CreateBuilder().table("t").build()); + } + + @Test + void throwsWhenNullTableWithColumns() { + assertThrows(IllegalStateException.class, + () -> new CreateBuilder().column("id", "INT").build()); + } + + @Test + void buildsWithDialect() { + SqlResult result = new CreateBuilder() + .table("users") + .column("id", "INT") + .build(SqlDialect.MYSQL); + assertEquals("CREATE TABLE users (id INT)", result.getSql()); + assertTrue(result.getParameters().isEmpty()); + } + + @Test + void buildsWithCompositePrimaryKey() { + SqlResult result = new CreateBuilder() + .table("user_roles") + .column("user_id", "INT") + .column("role_id", "INT") + .primaryKey("user_id") + .primaryKey("role_id") + .build(); + assertEquals( + "CREATE TABLE user_roles (user_id INT, role_id INT, PRIMARY KEY (user_id, role_id))", + result.getSql()); + } + + @Test + void parametersAreAlwaysEmpty() { + SqlResult result = new CreateBuilder() + .table("t") + .column("id", "INT") + .build(); + assertTrue(result.getParameters().isEmpty()); + } + + @Test + void buildsMultipleColumnsWithoutPrimaryKey() { + SqlResult result = new CreateBuilder() + .table("logs") + .column("id", "BIGINT") + .column("message", "TEXT") + .column("created_at", "TIMESTAMP") + .build(); + assertEquals("CREATE TABLE logs (id BIGINT, message TEXT, created_at TIMESTAMP)", result.getSql()); + } +} diff --git a/src/test/java/com/github/ezframework/javaquerybuilder/query/builder/DeleteBuilderTest.java b/src/test/java/com/github/ezframework/javaquerybuilder/query/builder/DeleteBuilderTest.java index 9adc421..9ed45f9 100644 --- a/src/test/java/com/github/ezframework/javaquerybuilder/query/builder/DeleteBuilderTest.java +++ b/src/test/java/com/github/ezframework/javaquerybuilder/query/builder/DeleteBuilderTest.java @@ -1,3 +1,4 @@ +// ...existing code... package com.github.ezframework.javaquerybuilder.query.builder; import java.util.List; @@ -9,6 +10,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; public class DeleteBuilderTest { @@ -55,4 +57,90 @@ void buildWithDialect() { assertNotNull(result); assertTrue(result.getSql().startsWith("DELETE FROM t")); } + + @Test + void buildSqlInCondition() { + SqlResult result = new DeleteBuilder().from("users").whereIn("id", List.of(1, 2, 3)).build(); + assertEquals("DELETE FROM users WHERE id IN (?, ?, ?)", result.getSql()); + assertEquals(List.of(1, 2, 3), result.getParameters()); + } + + @Test + void whereInNullThrows() { + assertThrows(IllegalArgumentException.class, + () -> new DeleteBuilder().from("t").whereIn("id", null)); + } + + @Test + void whereInEmptyThrows() { + assertThrows(IllegalArgumentException.class, + () -> new DeleteBuilder().from("t").whereIn("id", List.of())); + } + + @Test + void buildSqlNotInCondition() { + SqlResult result = new DeleteBuilder().from("users").whereNotIn("id", List.of(1, 2)).build(); + assertEquals("DELETE FROM users WHERE id NOT IN (?, ?)", result.getSql()); + assertEquals(List.of(1, 2), result.getParameters()); + } + + @Test + void whereNotInNullThrows() { + assertThrows(IllegalArgumentException.class, + () -> new DeleteBuilder().from("t").whereNotIn("id", null)); + } + + @Test + void whereNotInEmptyThrows() { + assertThrows(IllegalArgumentException.class, + () -> new DeleteBuilder().from("t").whereNotIn("id", List.of())); + } + + @Test + void buildSqlBetweenCondition() { + SqlResult result = new DeleteBuilder().from("logs").whereBetween("age", 10, 20).build(); + assertEquals("DELETE FROM logs WHERE age BETWEEN ? AND ?", result.getSql()); + assertEquals(List.of(10, 20), result.getParameters()); + } + + @Test + void buildSqlGreaterThanCondition() { + SqlResult result = new DeleteBuilder().from("t").whereGreaterThan("score", 100).build(); + assertEquals("DELETE FROM t WHERE score > ?", result.getSql()); + assertEquals(List.of(100), result.getParameters()); + } + + @Test + void buildSqlGreaterThanOrEqualsCondition() { + SqlResult result = new DeleteBuilder().from("t").whereGreaterThanOrEquals("age", 18).build(); + assertEquals("DELETE FROM t WHERE age >= ?", result.getSql()); + assertEquals(List.of(18), result.getParameters()); + } + + @Test + void buildSqlLessThanOrEqualsCondition() { + SqlResult result = new DeleteBuilder().from("t").whereLessThanOrEquals("price", 99).build(); + assertEquals("DELETE FROM t WHERE price <= ?", result.getSql()); + assertEquals(List.of(99), result.getParameters()); + } + + @Test + void buildSqlNotEqualsCondition() { + SqlResult result = new DeleteBuilder().from("t").whereNotEquals("status", "deleted").build(); + assertEquals("DELETE FROM t WHERE status != ?", result.getSql()); + assertEquals(List.of("deleted"), result.getParameters()); + } + + @Test + void buildSqlMultipleOperatorsCombined() { + SqlResult result = new DeleteBuilder().from("orders") + .whereGreaterThan("total", 0) + .whereLessThanOrEquals("age", 30) + .whereNotEquals("status", "active") + .build(); + assertTrue(result.getSql().contains("WHERE total > ?")); + assertTrue(result.getSql().contains("AND age <= ?")); + assertTrue(result.getSql().contains("AND status != ?")); + assertEquals(List.of(0, 30, "active"), result.getParameters()); + } } diff --git a/src/test/java/com/github/ezframework/javaquerybuilder/query/builder/QueryBuilderTest.java b/src/test/java/com/github/ezframework/javaquerybuilder/query/builder/QueryBuilderTest.java index b3f7edf..648598c 100644 --- a/src/test/java/com/github/ezframework/javaquerybuilder/query/builder/QueryBuilderTest.java +++ b/src/test/java/com/github/ezframework/javaquerybuilder/query/builder/QueryBuilderTest.java @@ -219,4 +219,89 @@ void fromStoresTableUsedByBuildAndNoArgBuildSql() { void buildSqlNoArgThrowsWhenNoTableSet() { assertThrows(IllegalStateException.class, () -> new QueryBuilder().buildSql()); } + + // --- Static factory: insert --- + + @Test + void insertStaticFactoryBuildsCorrectSql() { + SqlResult result = QueryBuilder.insert().into("users").value("name", "Alice").build(); + assertEquals("INSERT INTO users (name) VALUES (?)", result.getSql()); + assertEquals(List.of("Alice"), result.getParameters()); + } + + @Test + void insertIntoShortcutBuildsCorrectSql() { + SqlResult result = QueryBuilder.insertInto("users").value("name", "Alice").value("age", 30).build(); + assertEquals("INSERT INTO users (name, age) VALUES (?, ?)", result.getSql()); + assertEquals(List.of("Alice", 30), result.getParameters()); + } + + // --- Static factory: delete --- + + @Test + void deleteStaticFactoryBuildsCorrectSql() { + SqlResult result = QueryBuilder.delete().from("users").whereEquals("id", 1).build(); + assertEquals("DELETE FROM users WHERE id = ?", result.getSql()); + assertEquals(List.of(1), result.getParameters()); + } + + @Test + void deleteFromShortcutBuildsCorrectSql() { + SqlResult result = QueryBuilder.deleteFrom("users").whereEquals("id", 42).build(); + assertEquals("DELETE FROM users WHERE id = ?", result.getSql()); + assertEquals(List.of(42), result.getParameters()); + } + + @Test + void deleteFromShortcutNoConditions() { + SqlResult result = QueryBuilder.deleteFrom("sessions").build(); + assertEquals("DELETE FROM sessions", result.getSql()); + assertTrue(result.getParameters().isEmpty()); + } + + // --- Static factory: update --- + + @Test + void updateStaticFactoryBuildsCorrectSql() { + SqlResult result = QueryBuilder.update().table("users").set("name", "Bob").whereEquals("id", 1).build(); + assertTrue(result.getSql().startsWith("UPDATE users SET name = ?")); + assertTrue(result.getSql().contains("WHERE id = ?")); + assertEquals(List.of("Bob", 1), result.getParameters()); + } + + @Test + void updateShortcutBuildsCorrectSql() { + SqlResult result = QueryBuilder.update("users").set("status", "active").whereEquals("id", 5).build(); + assertEquals("UPDATE users SET status = ? WHERE id = ?", result.getSql()); + assertEquals(List.of("active", 5), result.getParameters()); + } + + // --- Static factory: createTable --- + + @Test + void createTableStaticFactoryBuildsCorrectSql() { + SqlResult result = QueryBuilder.createTable().table("roles").column("id", "INT").build(); + assertEquals("CREATE TABLE roles (id INT)", result.getSql()); + assertTrue(result.getParameters().isEmpty()); + } + + @Test + void createTableShortcutBuildsCorrectSql() { + SqlResult result = QueryBuilder.createTable("users") + .column("id", "INT") + .column("name", "VARCHAR(255)") + .primaryKey("id") + .build(); + assertEquals("CREATE TABLE users (id INT, name VARCHAR(255), PRIMARY KEY (id))", result.getSql()); + assertTrue(result.getParameters().isEmpty()); + } + + @Test + void createTableShortcutWithIfNotExists() { + SqlResult result = QueryBuilder.createTable("sessions") + .column("token", "VARCHAR(64)") + .ifNotExists() + .build(); + assertEquals("CREATE TABLE IF NOT EXISTS sessions (token VARCHAR(64))", result.getSql()); + } } diff --git a/src/test/java/com/github/ezframework/javaquerybuilder/query/builder/SelectBuilderTest.java b/src/test/java/com/github/ezframework/javaquerybuilder/query/builder/SelectBuilderTest.java new file mode 100644 index 0000000..d2971e6 --- /dev/null +++ b/src/test/java/com/github/ezframework/javaquerybuilder/query/builder/SelectBuilderTest.java @@ -0,0 +1,162 @@ +package com.github.ezframework.javaquerybuilder.query.builder; + +import java.util.Arrays; +import java.util.Collections; + +import org.junit.jupiter.api.Test; + +import com.github.ezframework.javaquerybuilder.query.sql.SqlDialect; +import com.github.ezframework.javaquerybuilder.query.sql.SqlResult; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class SelectBuilderTest { + + @Test + void testSimpleSelect() { + SqlResult sql = new SelectBuilder() + .from("users") + .select("id", "name") + .build(); + assertEquals("SELECT id, name FROM users", sql.getSql()); + assertTrue(sql.getParameters().isEmpty()); + } + + @Test + void testSelectAll() { + SqlResult sql = new SelectBuilder() + .from("users") + .build(); + assertEquals("SELECT * FROM users", sql.getSql()); + assertTrue(sql.getParameters().isEmpty()); + } + + @Test + void testDistinct() { + SqlResult sql = new SelectBuilder() + .from("users") + .select("email") + .distinct() + .build(); + assertEquals("SELECT DISTINCT email FROM users", sql.getSql()); + } + + @Test + void testWhereEquals() { + SqlResult sql = new SelectBuilder() + .from("users") + .whereEquals("id", 42) + .build(); + assertEquals("SELECT * FROM users WHERE id = ?", sql.getSql()); + assertEquals(Collections.singletonList(42), sql.getParameters()); + } + + @Test + void testWhereIn() { + SqlResult sql = new SelectBuilder() + .from("users") + .whereIn("id", Arrays.asList(1, 2, 3)) + .build(); + assertEquals("SELECT * FROM users WHERE id IN (?, ?, ?)", sql.getSql()); + assertEquals(Arrays.asList(1, 2, 3), sql.getParameters()); + } + + @Test + void testWhereLike() { + SqlResult sql = new SelectBuilder() + .from("users") + .whereLike("name", "%bob%") + .build(); + assertEquals("SELECT * FROM users WHERE name LIKE ?", sql.getSql()); + assertEquals(Collections.singletonList("%bob%"), sql.getParameters()); + } + + @Test + void testGroupByOrderByLimitOffset() { + SqlResult sql = new SelectBuilder() + .from("users") + .select("id", "name") + .groupBy("name") + .orderBy("id", false) + .limit(10) + .offset(5) + .build(); + assertEquals("SELECT id, name FROM users GROUP BY name ORDER BY id DESC LIMIT 10 OFFSET 5", sql.getSql()); + } + + @Test + void testMissingTableThrows() { + final SelectBuilder builder = new SelectBuilder(); + final IllegalStateException ex = assertThrows(IllegalStateException.class, builder::build); + assertTrue(ex.getMessage().contains("Table name")); + } + + @Test + void testBuildWithDialect() { + SqlResult sql = new SelectBuilder() + .from("users") + .select("id") + .whereEquals("active", true) + .build(SqlDialect.STANDARD); + // SelectBuilder does not apply dialect-based identifier quoting + assertEquals("SELECT id FROM users WHERE active = ?", sql.getSql()); + assertEquals(Collections.singletonList(true), sql.getParameters()); + } + + @Test + void testMultipleWhereConditions() { + SqlResult sql = new SelectBuilder() + .from("products") + .whereEquals("category", "electronics") + .whereEquals("in_stock", true) + .build(); + assertEquals("SELECT * FROM products WHERE category = ? AND in_stock = ?", sql.getSql()); + assertEquals(Arrays.asList("electronics", true), sql.getParameters()); + } + + @Test + void testMultipleOrderByColumns() { + SqlResult sql = new SelectBuilder() + .from("employees") + .orderBy("department", true) + .orderBy("salary", false) + .build(); + assertEquals("SELECT * FROM employees ORDER BY department ASC, salary DESC", sql.getSql()); + } + + @Test + void testMultipleGroupByColumns() { + SqlResult sql = new SelectBuilder() + .from("sales") + .select("region", "product") + .groupBy("region", "product") + .build(); + assertEquals("SELECT region, product FROM sales GROUP BY region, product", sql.getSql()); + } + + @Test + void testLimitWithoutOffset() { + SqlResult sql = new SelectBuilder().from("t").limit(25).build(); + assertEquals("SELECT * FROM t LIMIT 25", sql.getSql()); + } + + @Test + void testOffsetWithoutLimit() { + SqlResult sql = new SelectBuilder().from("t").offset(10).build(); + assertEquals("SELECT * FROM t OFFSET 10", sql.getSql()); + } + + @Test + void testWhereEqualsChainsCorrectly() { + SqlResult sql = new SelectBuilder() + .from("t") + .whereEquals("a", 1) + .whereEquals("b", 2) + .whereEquals("c", 3) + .build(); + assertEquals("SELECT * FROM t WHERE a = ? AND b = ? AND c = ?", sql.getSql()); + assertEquals(Arrays.asList(1, 2, 3), sql.getParameters()); + } +} diff --git a/src/test/java/feature/query/QueryBuilderFeatureTest.java b/src/test/java/feature/query/QueryBuilderFeatureTest.java index fd4e41a..726623e 100644 --- a/src/test/java/feature/query/QueryBuilderFeatureTest.java +++ b/src/test/java/feature/query/QueryBuilderFeatureTest.java @@ -1,14 +1,17 @@ package feature.query; -import com.github.ezframework.javaquerybuilder.query.builder.QueryBuilder; -import com.github.ezframework.javaquerybuilder.query.sql.SqlDialect; -import com.github.ezframework.javaquerybuilder.query.sql.SqlResult; +import java.util.List; + import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; -import java.util.List; +import com.github.ezframework.javaquerybuilder.query.builder.QueryBuilder; +import com.github.ezframework.javaquerybuilder.query.sql.SqlDialect; +import com.github.ezframework.javaquerybuilder.query.sql.SqlResult; -import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; /** * Feature test: End-to-end SQL generation for complex queries. @@ -28,7 +31,7 @@ void buildsComplexSelectWithDialect() { .limit(5) .offset(10) .buildSql("users", SqlDialect.MYSQL); - String sql = result.getSql(); + final String sql = result.getSql(); assertTrue(sql.contains("SELECT `id`, `name`, `email` FROM `users`"), sql); assertTrue(sql.contains("`status` = ?"), sql); assertTrue(sql.contains("`email` LIKE ?"), sql); @@ -42,7 +45,88 @@ void buildsComplexSelectWithDialect() { @Test @DisplayName("Throws when building SQL without table") void throwsWhenNoTable() { - QueryBuilder builder = new QueryBuilder().select("id"); + final QueryBuilder builder = new QueryBuilder().select("id"); assertThrows(IllegalStateException.class, builder::buildSql); } + + @Test + @DisplayName("insertInto shortcut produces correct parameterized INSERT") + void insertIntoShortcutEndToEnd() { + SqlResult result = QueryBuilder.insertInto("users") + .value("name", "Alice") + .value("email", "alice@example.com") + .value("age", 30) + .build(); + assertEquals("INSERT INTO users (name, email, age) VALUES (?, ?, ?)", result.getSql()); + assertEquals(List.of("Alice", "alice@example.com", 30), result.getParameters()); + } + + @Test + @DisplayName("deleteFrom shortcut with multiple conditions produces correct DELETE") + void deleteFromShortcutEndToEnd() { + SqlResult result = QueryBuilder.deleteFrom("audit_log") + .whereLessThan("created_at", "2024-01-01") + .whereNotEquals("level", "ERROR") + .build(); + assertEquals("DELETE FROM audit_log WHERE created_at < ? AND level != ?", result.getSql()); + assertEquals(List.of("2024-01-01", "ERROR"), result.getParameters()); + } + + @Test + @DisplayName("deleteFrom shortcut with IN and NOT IN produces correct DELETE") + void deleteFromWithInAndNotIn() { + SqlResult result = QueryBuilder.deleteFrom("sessions") + .whereIn("status", List.of("expired", "revoked")) + .whereNotIn("user_id", List.of(1, 2, 3)) + .build(); + assertEquals( + "DELETE FROM sessions WHERE status IN (?, ?) AND user_id NOT IN (?, ?, ?)", + result.getSql()); + assertEquals(List.of("expired", "revoked", 1, 2, 3), result.getParameters()); + } + + @Test + @DisplayName("update shortcut with set and where produces correct UPDATE") + void updateShortcutEndToEnd() { + SqlResult result = QueryBuilder.update("subscribers") + .set("active", false) + .set("unsubscribed_at", "2026-04-16") + .whereEquals("email", "spam@example.com") + .build(); + final String sql = result.getSql(); + assertTrue(sql.startsWith("UPDATE subscribers SET"), sql); + assertTrue(sql.contains("active = ?"), sql); + assertTrue(sql.contains("unsubscribed_at = ?"), sql); + assertTrue(sql.contains("WHERE email = ?"), sql); + assertTrue(result.getParameters().contains("spam@example.com")); + } + + @Test + @DisplayName("createTable shortcut with composite PK and IF NOT EXISTS") + void createTableShortcutEndToEnd() { + SqlResult result = QueryBuilder.createTable("permissions") + .column("user_id", "INT") + .column("resource_id", "INT") + .column("granted_at", "TIMESTAMP") + .primaryKey("user_id") + .primaryKey("resource_id") + .ifNotExists() + .build(); + assertEquals( + "CREATE TABLE IF NOT EXISTS permissions" + + " (user_id INT, resource_id INT, granted_at TIMESTAMP," + + " PRIMARY KEY (user_id, resource_id))", + result.getSql()); + assertTrue(result.getParameters().isEmpty()); + } + + @Test + @DisplayName("deleteFrom with BETWEEN produces correct range DELETE") + void deleteFromWithBetween() { + SqlResult result = QueryBuilder.deleteFrom("metrics") + .whereBetween("recorded_at", "2024-01-01", "2024-12-31") + .build(); + assertEquals("DELETE FROM metrics WHERE recorded_at BETWEEN ? AND ?", result.getSql()); + assertEquals(List.of("2024-01-01", "2024-12-31"), result.getParameters()); + } }