diff --git a/src/main/java/org/javawebstack/orm/query/Query.java b/src/main/java/org/javawebstack/orm/query/Query.java index fac766c..9bbd617 100644 --- a/src/main/java/org/javawebstack/orm/query/Query.java +++ b/src/main/java/org/javawebstack/orm/query/Query.java @@ -296,20 +296,43 @@ public Query search(String search) { return this; } - public Query order(String orderBy) { - return order(orderBy, false); - } - - public Query order(String orderBy, boolean desc) { - return order(new QueryColumn(orderBy), desc); - } - - public Query order(QueryColumn orderBy, boolean desc) { - boolean success = this.order.add(orderBy, desc); + /** + * Sorts the results by the given column name ascendingly. + * + * @param columnName The name of the column to sort ascendingly by. + * @return The Query object with the given order by information added. + * @throws ORMQueryException if the order operation is called twice on a column specification with the same name. + */ + public Query order(String columnName) throws ORMQueryException { + return order(columnName, false); + } + + /** + * Sorts the results by the given column name with the given order direction. + * + * @param columnName The name of the column to sort ascendingly by. + * @param desc If true it will order descendingly, if false it will order ascendingly. + * @return The Query object with the given order by information added. + * @throws ORMQueryException if the order operation is called twice on a column specification with the same name. + */ + public Query order(String columnName, boolean desc) throws ORMQueryException { + return order(new QueryColumn(columnName), desc); + } + + /** + * Sorts the results by the given column with the given order direction. + * + * @param column The column encoded as QueryColumn object. + * @param desc If true it will order descendingly, if false it will order ascendingly. + * @return The Query object with the given order by information added. + * @throws ORMQueryException if the order operation is called twice on a column specification with the same name. + */ + public Query order(QueryColumn column, boolean desc) throws ORMQueryException{ + boolean success = this.order.add(column, desc); if(!success) { throw new ORMQueryException(String.format( "The column %s could not be ordered %s. This is probably caused by calling .order() on this column twice.", - orderBy.toString(), + column.toString(), desc ? "descendingly" : "ascendingly" )); } diff --git a/src/main/java/org/javawebstack/orm/query/QueryOrderBy.java b/src/main/java/org/javawebstack/orm/query/QueryOrderBy.java index d35529f..7cc6e24 100644 --- a/src/main/java/org/javawebstack/orm/query/QueryOrderBy.java +++ b/src/main/java/org/javawebstack/orm/query/QueryOrderBy.java @@ -1,19 +1,48 @@ package org.javawebstack.orm.query; +import org.javawebstack.orm.TableInfo; + import java.util.LinkedList; -import java.util.List; +import java.util.stream.Collectors; +/** + * The QueryOrderBy class serves as an aggregation of order by elements. It extends a list, because the order of the + * order by statements is of relevance. + */ public class QueryOrderBy extends LinkedList{ + /** + * Add a new order by statement. If a statement with the same column name already exists it will not add the + * statement. + * + * @param columnName The column name to order by. + * @param desc If the column should be order descendingly. + * @return True if adding the statement was successful. False otherwise. + */ public boolean add(String columnName, boolean desc) { return this.add(new QueryColumn(columnName), desc); } + /** + * Add a new order by statement. If a statement with the same column name already exists it will not add the + * statement. + * + * @param column The column to be ordered by. It will retrieve the name from the QueryColumn. + * @param desc If the column should be order descendingly. + * @return True if adding the statement was successful. False otherwise. + */ public boolean add(QueryColumn column, boolean desc) { return this.add(new QueryOrderByElement(column, desc)); } @Override + /** + * Add a new order by statement. If a statement with the same column name already exists it will not add the + * statement. + * + * @param element The direct QueryOrderByElement which encodes the order by statement. + * @return True if adding the statement was successful. False otherwise. + */ public boolean add(QueryOrderByElement element) { boolean hasBeenAdded = false; if(!willOverwrite(element)) @@ -25,4 +54,17 @@ public boolean add(QueryOrderByElement element) { private boolean willOverwrite(QueryOrderByElement element) { return this.stream().anyMatch(element::hasEqualColumn); } + + + // The toString methods are specific to MySQL so they might have to be replaced later on. + @Override + public String toString() { + return toString(null); + } + + public String toString(TableInfo info) { + return this.stream() + .map(QueryOrderByElement::toString) + .collect(Collectors.joining(",")); + } } diff --git a/src/main/java/org/javawebstack/orm/query/QueryOrderByElement.java b/src/main/java/org/javawebstack/orm/query/QueryOrderByElement.java index 23bf426..915284f 100644 --- a/src/main/java/org/javawebstack/orm/query/QueryOrderByElement.java +++ b/src/main/java/org/javawebstack/orm/query/QueryOrderByElement.java @@ -1,10 +1,15 @@ package org.javawebstack.orm.query; +import org.javawebstack.orm.TableInfo; + import java.util.Objects; +/** + * The QueryOrderByElement class encodes an Order By Statement. + */ public class QueryOrderByElement { - private QueryColumn queryColumn; - private boolean desc; + private final QueryColumn queryColumn; + private final boolean desc; QueryOrderByElement(String columnName, boolean desc) { queryColumn = new QueryColumn(columnName); @@ -16,14 +21,30 @@ public class QueryOrderByElement { this.desc = desc; } + /** + * Retrieves the QueryColumn of the statement which encodes the column name. + * + * @return The encoding QueryColumn object. + */ public QueryColumn getQueryColumn() { return queryColumn; } + /** + * Retrieves the information if this column is ordered ascendingly or descendingly. + * + * @return false if ascending, true if descending. + */ public boolean isDesc() { return desc; } + /** + * Compares the encoded column name. + * + * @param o An object to compare to. + * @return True if the object is a QueryOrderByElement with a QueryColumn with generates the same identifier. + */ public boolean hasEqualColumn(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; @@ -31,13 +52,6 @@ public boolean hasEqualColumn(Object o) { return getQueryColumn().equals(that.getQueryColumn()); } - public boolean hasEqualOrderDirection(Object o) { - if (this == o) return true; - if (o == null || getClass() != o.getClass()) return false; - QueryOrderByElement that = (QueryOrderByElement) o; - return isDesc() == that.isDesc(); - } - @Override public boolean equals(Object o) { if (this == o) return true; @@ -50,4 +64,17 @@ public boolean equals(Object o) { public int hashCode() { return Objects.hash(getQueryColumn(), isDesc()); } + + @Override + public String toString() { + return this.toString(null); + } + + public String toString(TableInfo info) { + String stringifiedOrderBy = getQueryColumn().toString(info); + if (isDesc()) + stringifiedOrderBy += " DESC"; + + return stringifiedOrderBy; + } } diff --git a/src/main/java/org/javawebstack/orm/wrapper/builder/MySQLQueryStringBuilder.java b/src/main/java/org/javawebstack/orm/wrapper/builder/MySQLQueryStringBuilder.java index bfc1771..3b1faef 100644 --- a/src/main/java/org/javawebstack/orm/wrapper/builder/MySQLQueryStringBuilder.java +++ b/src/main/java/org/javawebstack/orm/wrapper/builder/MySQLQueryStringBuilder.java @@ -50,11 +50,13 @@ public SQLQueryString buildQuery(Query query, boolean count) { sb.append(" WHERE ").append(qs.getQuery()); parameters.addAll(qs.getParameters()); } - if (query.getOrder() != null) { - sb.append(" ORDER BY ").append(query.getOrder().toString(repo.getInfo())); - if (query.isDescOrder()) - sb.append(" DESC"); + + QueryOrderBy orderBy = query.getOrder(); + if (!orderBy.isEmpty()) { + sb.append(" ORDER BY ") + .append(orderBy.toString()); } + Integer offset = query.getOffset(); Integer limit = query.getLimit(); if (offset != null && limit == null) diff --git a/src/test/java/org/javawebstack/orm/test/exception/SectionIndexOutOfBoundException.java b/src/test/java/org/javawebstack/orm/test/exception/SectionIndexOutOfBoundException.java new file mode 100644 index 0000000..1a40ee7 --- /dev/null +++ b/src/test/java/org/javawebstack/orm/test/exception/SectionIndexOutOfBoundException.java @@ -0,0 +1,17 @@ +package org.javawebstack.orm.test.exception; + +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +/** + * Only to be used for tests. + * This exception should be thrown when a SQL Query String is manually parsed and sections and section types are defined, and + * a type of section is attempted to be retrieved which does not exist in this number. + */ +public class SectionIndexOutOfBoundException extends Exception { + private int sectionCount; + private int attemptedIndex; + private String topLevelKeyword; +} diff --git a/src/test/java/org/javawebstack/orm/test/querybuilding/OrderByClauseTest.java b/src/test/java/org/javawebstack/orm/test/querybuilding/OrderByClauseTest.java new file mode 100644 index 0000000..d411536 --- /dev/null +++ b/src/test/java/org/javawebstack/orm/test/querybuilding/OrderByClauseTest.java @@ -0,0 +1,156 @@ +package org.javawebstack.orm.test.querybuilding; + +import org.javawebstack.orm.exception.ORMQueryException; +import org.javawebstack.orm.query.Query; +import org.javawebstack.orm.test.exception.SectionIndexOutOfBoundException; +import org.javawebstack.orm.test.shared.models.Datatype; +import org.javawebstack.orm.test.shared.verification.QueryVerification; +import org.junit.jupiter.api.Test; + +import javax.xml.crypto.Data; + +import java.util.*; +import java.util.stream.Collectors; + +import static org.javawebstack.orm.test.shared.setup.ModelSetup.setUpModel; +import static org.junit.jupiter.api.Assertions.*; + +// This class tests the query generation for order by statements an MySQL +public class OrderByClauseTest { + + @Test + void testOneExistingColumnDefaultOrderBy() { + Query query = setUpModel(Datatype.class).query() + .order("wrapper_integer"); + new QueryVerification(query).assertSectionEquals("ORDER BY", "`wrapper_integer`"); + } + + @Test + void testOneNonExistingColumnDefaultOrderBy() { + Query query = setUpModel(Datatype.class).query() + .order("does_not_exist"); + new QueryVerification(query).assertSectionEquals("ORDER BY", "`does_not_exist`"); + } + + @Test + void testOneExistingColumnASCOrderBy() { + Query query = setUpModel(Datatype.class).query() + .order("wrapper_integer", false); + new QueryVerification(query).assertSectionEquals("ORDER BY", "`wrapper_integer`"); + } + + @Test + void testOneNonExistingColumnASCOrderBy() { + Query query = setUpModel(Datatype.class).query() + .order("does_not_exist", false); + new QueryVerification(query).assertSectionEquals("ORDER BY", "`does_not_exist`"); + } + + @Test + void testOneExistingColumnDESCOrderBy() { + Query query = setUpModel(Datatype.class).query() + .order("wrapper_integer", true); + new QueryVerification(query).assertSectionEquals("ORDER BY", "`wrapper_integer` DESC"); + } + + @Test + void testOneNonExistingColumnDESCOrderBy() { + Query query = setUpModel(Datatype.class).query() + .order("does_not_exist", true); + new QueryVerification(query).assertSectionEquals("ORDER BY", "`does_not_exist` DESC"); + } + + @Test + void testMultipleOrderByClausesOfASCOrder() { + Query query = setUpModel(Datatype.class).query() + .order("wrapper_integer") + .order("primitive_integer"); + + new QueryVerification(query) + .assertSectionContains("ORDER BY", "`wrapper_integer`") + .assertSectionContains("ORDER BY", "`primitive_integer`"); + } + + @Test + void testMultipleOrderByClausesOfDESCOrder() { + Query query = setUpModel(Datatype.class).query() + .order("wrapper_integer", true) + .order("primitive_integer", true); + + new QueryVerification(query) + .assertSectionContains("ORDER BY", "`wrapper_integer` DESC") + .assertSectionContains("ORDER BY", "`primitive_integer` DESC"); + } + + @Test + void testMultipleOrderByClausesOfMixedOrder() { + Query query = setUpModel(Datatype.class).query() + .order("wrapper_integer", false) + .order("primitive_integer", true); + + new QueryVerification(query) + .assertSectionContains("ORDER BY", "`wrapper_integer`") + .assertSectionContains("ORDER BY", "`primitive_integer` DESC"); + } + + @Test + void testMultipleOrderByClausesOfMixedOrderReversed() { + Query query = setUpModel(Datatype.class).query() + .order("primitive_integer", true) + .order("wrapper_integer", false); + + new QueryVerification(query) + .assertSectionContains("ORDER BY", "`primitive_integer` DESC") + .assertSectionContains("ORDER BY", "`wrapper_integer`"); + } + + + @Test + // This test is important because putting the order by statements in different order is relevant (they set priorities) + void testMultipleOrderByClausesOfRandomOrderForCorrectOrder() throws SectionIndexOutOfBoundException { + Query query = setUpModel(Datatype.class).query(); + ArrayList columnNames = new ArrayList<>(Datatype.columnNames); + + LinkedList callOrder = new LinkedList<>(); + + Random r = new Random(); + columnNames.stream().unordered().forEach((singleColumn) -> { + query.order(singleColumn, r.nextBoolean()); + callOrder.add(singleColumn); + }); + + String queryString = new QueryVerification(query).getSection("ORDER BY"); + int lastIndex = 0; + int foundIndex = -1; + for (String nextInCallOrder : callOrder) { + foundIndex = queryString.indexOf("`" + nextInCallOrder + "`"); + if(foundIndex < lastIndex) { + if (foundIndex == -1) + fail("Not all columns occurred in the query string."); + else + fail("The columns did not appear an the correct order."); + + break; + } + + lastIndex = foundIndex; + } + + // If it came until here the test should count as passed. + assertTrue(true); + + } + + /* + * Error Cases + */ + + // This test might not be correct here as it does not purely look at the query + @Test + void testCannotCallOrderOnSameColumnTwice() { + Query query = setUpModel(Datatype.class).query() + .order("primitive_integer", true); + + assertThrows(ORMQueryException.class, () -> query.order("primitive_integer")); + } +} diff --git a/src/test/java/org/javawebstack/orm/test/shared/models/Datatype.java b/src/test/java/org/javawebstack/orm/test/shared/models/Datatype.java index 7b4aa9d..5d79a8d 100644 --- a/src/test/java/org/javawebstack/orm/test/shared/models/Datatype.java +++ b/src/test/java/org/javawebstack/orm/test/shared/models/Datatype.java @@ -5,6 +5,8 @@ import java.sql.Date; import java.sql.Timestamp; +import java.util.Arrays; +import java.util.List; import java.util.UUID; /* @@ -84,4 +86,30 @@ public enum OptionEnum { OPTION1, OPTION2, } + + public static final List columnNames = Arrays.asList( + "id", + "primitive_boolean", + "wrapper_boolean", + "primitive_byte", + "wrapper_byte", + "primitive_short", + "wrapper_short", + "primitive_integer", + "wrapper_integer", + "primitive_long", + "wrapper_long", + "primitive_float", + "wrapper_float", + "primitive_double", + "wrapper_double", + "primitive_char", + "wrapper_string", + "char_array", + "byte_array", + "timestamp", + "date", + "uuid", + "option_enum" + ); } \ No newline at end of file diff --git a/src/test/java/org/javawebstack/orm/test/shared/verification/QueryVerification.java b/src/test/java/org/javawebstack/orm/test/shared/verification/QueryVerification.java index a806819..442d7de 100644 --- a/src/test/java/org/javawebstack/orm/test/shared/verification/QueryVerification.java +++ b/src/test/java/org/javawebstack/orm/test/shared/verification/QueryVerification.java @@ -1,12 +1,13 @@ package org.javawebstack.orm.test.shared.verification; import org.javawebstack.orm.query.Query; +import org.javawebstack.orm.test.exception.SectionIndexOutOfBoundException; import org.javawebstack.orm.test.shared.util.QueryStringUtil; import java.util.HashSet; +import java.util.List; -import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.junit.jupiter.api.Assertions.fail; +import static org.junit.jupiter.api.Assertions.*; /** * The QueryVerification class wraps an JWS Query Object and provides assertion methods regarding the raw query string. @@ -22,7 +23,6 @@ public class QueryVerification { public QueryVerification(Query query) { this.query = query; - } /** @@ -32,8 +32,9 @@ public QueryVerification(Query query) { * @param topLevelKeyword The top level keyword that prefaces the section. * @param containedSubstring The substring which should be contained in the first section of the given type. */ - public void assertSectionContains(String topLevelKeyword, String containedSubstring) { + public QueryVerification assertSectionContains(String topLevelKeyword, String containedSubstring) { this.assertSectionContains(topLevelKeyword, containedSubstring, 0); + return this; } /** @@ -42,30 +43,119 @@ public void assertSectionContains(String topLevelKeyword, String containedSubstr * * @param topLevelKeyword The top level keyword that prefaces the section. * @param containedSubstring The substring which should be contained in the first section of the given type. - * @param sectionIndex The index of the section to be checked, thusly 0 refers to the first occurrence etc. + * @param sectionIndex The index of the section to be checked, thus 0 refers to the first occurrence etc. */ - public void assertSectionContains(String topLevelKeyword, String containedSubstring, int sectionIndex) { - String sectionString; - + public QueryVerification assertSectionContains(String topLevelKeyword, String containedSubstring, int sectionIndex) { + String sectionString = null; try { - sectionString = new QueryStringUtil(this.query.getRepo().getConnection().builder().buildQuery(this.query, false).getQuery()) - .getTopLevelSectionsByKeyword(topLevelKeyword) - .get(sectionIndex); - } catch (IndexOutOfBoundsException ignored) { - fail(String.format( - "A top level section of type %s and index %d was tested but only %d sections of that type existed.", - sectionIndex, - topLevelKeyword, - sectionIndex + 1 - )); - - return; + sectionString = getSection(topLevelKeyword, sectionIndex); + } catch (SectionIndexOutOfBoundException e) { + this.failDueToSectionIndexOutOfBounds(e); + return this; } assertTrue( - sectionString.contains(containedSubstring), - String.format("The occurrence of index %d of %s section of the query did not contain a substring %s but looked like this: %s. Note that the match is case-sensitive.", sectionIndex, topLevelKeyword, containedSubstring, sectionString) + sectionString.contains(containedSubstring), + String.format("The occurrence of index %d of %s section of the query did not contain a substring %s but looked like this: %s. Note that the match is case-sensitive.", sectionIndex, topLevelKeyword, containedSubstring, sectionString) + ); + + return this; + } + + /** + * Asserts that the first occurring section of a given top level keyword is equal to the given string. + * This method uses the String.equals method internally and is therefore case sensitive. + * + * @param topLevelKeyword The top level keyword that prefaces the section. + * @param expectedString The substring which the first section of the given type should be equal to. + */ + public QueryVerification assertSectionEquals(String topLevelKeyword, String expectedString) { + this.assertSectionEquals(topLevelKeyword, expectedString, 0); + return this; + } + + /** + * Asserts that the i-th occurring section of a given top level keyword equal to the given string. + * This method uses the String.equals method internally and is therefore case sensitive. + * + * @param topLevelKeyword The top level keyword that prefaces the section. + * @param expectedString The substring which the specified section of the given type should be equal to. + * @param sectionIndex The index of the section to be checked, thus 0 refers to the first occurrence etc. + */ + public QueryVerification assertSectionEquals(String topLevelKeyword, String expectedString, int sectionIndex) { + String sectionString = null; + try { + sectionString = getSection(topLevelKeyword, sectionIndex); + } catch (SectionIndexOutOfBoundException e) { + this.failDueToSectionIndexOutOfBounds(e); + return this; + } + + assertEquals( + expectedString, + sectionString, + String.format("The occurrence of index %d of %s section of the query was not equal to the string %s but looked like this: %s. Note that the match is case-sensitive.", sectionIndex, topLevelKeyword, expectedString, sectionString) ); + + return this; + } + + /** + * Retrieves the inner part of a section by its keyword. With multiple occurrences it will only retrieve the first + * one. It does not include the keyword and one whitespaces at start and end. + * + * @param topLevelKeyword The top level keyword that prefaces the section. + * @return The inner part of the first section as specified. + * @throws SectionIndexOutOfBoundException if no section by that top level keyword exists. + */ + public String getSection(String topLevelKeyword) throws SectionIndexOutOfBoundException { + return this.getSection(topLevelKeyword, 0); + } + + /** + * Retrieves the inner part of a section by its keyword and index specifying which occurrence should be retrieved. + * It does not include the keyword and one whitespaces at start and end. + * + * @param topLevelKeyword The top level keyword that prefaces the section. + * @param sectionIndex The index of the section to be retrieved, thus 0 refers to the first occurrence etc. + * @return The inner part of the first section as specified. + * @throws SectionIndexOutOfBoundException if there are less than sectionIndex + 1 elements + */ + public String getSection(String topLevelKeyword, int sectionIndex) throws SectionIndexOutOfBoundException { + List sectionList = this.getSectionList(topLevelKeyword); + try { + return sectionList.get(sectionIndex); + } catch (IndexOutOfBoundsException converted) { + + SectionIndexOutOfBoundException exception = new SectionIndexOutOfBoundException(); + + exception.setSectionCount(sectionList.size()); + exception.setAttemptedIndex(sectionIndex); + exception.setTopLevelKeyword(topLevelKeyword); + + throw exception; + } + } + + /** + * Retrieve list of all sections prefaced with the specified top level keyword. The list will have the same order + * as the occurrences of each section. + * + * @param topLevelKeyword The top level keyword that prefaces the sections. + * @return The order sensitive string list of inner sections. + */ + public List getSectionList(String topLevelKeyword) { + return new QueryStringUtil(this.query.getRepo().getConnection().builder().buildQuery(this.query, false).getQuery()) + .getTopLevelSectionsByKeyword(topLevelKeyword); + } + + private void failDueToSectionIndexOutOfBounds(SectionIndexOutOfBoundException exception) { + fail(String.format( + "A top level section of type %s and index %d was tested but only %d sections of that type existed.", + exception.getTopLevelKeyword(), + exception.getAttemptedIndex(), + exception.getSectionCount() + )); } }