From a26557e76bb2d7e7871e02072ee0b3acb2634ce0 Mon Sep 17 00:00:00 2001 From: Jens Schauder Date: Fri, 28 Apr 2023 11:49:59 +0200 Subject: [PATCH] Introduce SqlSort. SqlSort allows the specification of unsafe order-by-expressions. Order-by-expressions that are not declared unsafe are only accepted when they either match a property or consist only of digits, letters, underscore, dot, or parentheses. Closes #1507 --- .../data/jdbc/core/convert/QueryMapper.java | 14 +- ...JdbcAggregateTemplateIntegrationTests.java | 12 + .../core/convert/QueryMapperUnitTests.java | 65 +++- .../data/r2dbc/query/QueryMapper.java | 17 +- .../r2dbc/query/QueryMapperUnitTests.java | 40 +++ .../data/relational/domain/SqlSort.java | 293 ++++++++++++++++++ .../relational/domain/SqlSortUnitTests.java | 86 +++++ 7 files changed, 517 insertions(+), 10 deletions(-) create mode 100644 spring-data-relational/src/main/java/org/springframework/data/relational/domain/SqlSort.java create mode 100644 spring-data-relational/src/test/java/org/springframework/data/relational/domain/SqlSortUnitTests.java diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/QueryMapper.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/QueryMapper.java index 9a76f532..e21f031e 100644 --- a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/QueryMapper.java +++ b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/QueryMapper.java @@ -41,6 +41,7 @@ import org.springframework.data.relational.core.query.CriteriaDefinition; import org.springframework.data.relational.core.query.CriteriaDefinition.Comparator; import org.springframework.data.relational.core.query.ValueFunction; import org.springframework.data.relational.core.sql.*; +import org.springframework.data.relational.domain.SqlSort; import org.springframework.data.util.Pair; import org.springframework.data.util.TypeInformation; import org.springframework.jdbc.core.namedparam.MapSqlParameterSource; @@ -92,13 +93,22 @@ public class QueryMapper { for (Sort.Order order : sort) { - Field field = createPropertyField(entity, SqlIdentifier.unquoted(order.getProperty()), this.mappingContext); - OrderByField orderBy = OrderByField.from(table.column(field.getMappedColumnName())) + OrderByField simpleOrderByField = createSimpleOrderByField(table, entity, order); + OrderByField orderBy = simpleOrderByField .withNullHandling(order.getNullHandling()); mappedOrder.add(order.isAscending() ? orderBy.asc() : orderBy.desc()); } return mappedOrder; + + } + + private OrderByField createSimpleOrderByField(Table table, RelationalPersistentEntity entity, Sort.Order order) { + + SqlSort.validate(order); + + Field field = createPropertyField(entity, SqlIdentifier.unquoted(order.getProperty()), this.mappingContext); + return OrderByField.from(table.column(field.getMappedColumnName())); } /** diff --git a/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/JdbcAggregateTemplateIntegrationTests.java b/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/JdbcAggregateTemplateIntegrationTests.java index b95e9ebf..2c32fcae 100644 --- a/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/JdbcAggregateTemplateIntegrationTests.java +++ b/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/JdbcAggregateTemplateIntegrationTests.java @@ -62,6 +62,7 @@ import org.springframework.data.jdbc.testing.AssumeFeatureTestExecutionListener; import org.springframework.data.jdbc.testing.EnabledOnFeature; import org.springframework.data.jdbc.testing.TestConfiguration; import org.springframework.data.jdbc.testing.TestDatabaseFeatures; +import org.springframework.data.mapping.context.InvalidPersistentPropertyPath; import org.springframework.data.relational.core.conversion.DbActionExecutionException; import org.springframework.data.relational.core.mapping.Column; import org.springframework.data.relational.core.mapping.InsertOnlyProperty; @@ -275,6 +276,17 @@ class JdbcAggregateTemplateIntegrationTests { .containsExactly("Frozen", "Star", null); } + + @Test // + @EnabledOnFeature({ SUPPORTS_QUOTED_IDS}) + void findByNonPropertySortFails() { + + assertThatThrownBy(() -> template.findAll(LegoSet.class, + Sort.by("somethingNotExistant"))).isInstanceOf(InvalidPersistentPropertyPath.class); + + } + + @Test // DATAJDBC-112 @EnabledOnFeature(SUPPORTS_QUOTED_IDS) void saveAndLoadManyEntitiesByIdWithReferencedEntity() { diff --git a/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/convert/QueryMapperUnitTests.java b/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/convert/QueryMapperUnitTests.java index 678b48e2..a09fcade 100644 --- a/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/convert/QueryMapperUnitTests.java +++ b/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/convert/QueryMapperUnitTests.java @@ -21,13 +21,12 @@ import static org.springframework.data.domain.Sort.Order.*; import java.util.Collections; import java.util.List; +import java.util.Objects; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; import org.springframework.data.domain.Sort; -import org.springframework.data.jdbc.core.convert.BasicJdbcConverter; -import org.springframework.data.jdbc.core.convert.JdbcConverter; -import org.springframework.data.jdbc.core.convert.QueryMapper; -import org.springframework.data.jdbc.core.convert.RelationResolver; import org.springframework.data.jdbc.core.mapping.JdbcMappingContext; import org.springframework.data.relational.core.dialect.PostgresDialect; import org.springframework.data.relational.core.mapping.Column; @@ -37,12 +36,14 @@ import org.springframework.data.relational.core.sql.Expression; import org.springframework.data.relational.core.sql.Functions; import org.springframework.data.relational.core.sql.OrderByField; import org.springframework.data.relational.core.sql.Table; +import org.springframework.data.relational.domain.SqlSort; import org.springframework.jdbc.core.namedparam.MapSqlParameterSource; /** * Unit tests for {@link QueryMapper}. * * @author Mark Paluch + * @author Jens Schauder */ public class QueryMapperUnitTests { @@ -376,8 +377,60 @@ public class QueryMapperUnitTests { List fields = mapper.getMappedSort(Table.create("tbl"), sort, context.getRequiredPersistentEntity(Person.class)); - assertThat(fields).hasSize(1); - assertThat(fields.get(0)).hasToString("tbl.\"another_name\" DESC"); + assertThat(fields) // + .extracting(Objects::toString) // + .containsExactly("tbl.\"another_name\" DESC"); + } + + @Test // GH-1507 + public void shouldMapSortWithUnknownField() { + + Sort sort = Sort.by(desc("unknownField")); + + List fields = mapper.getMappedSort(Table.create("tbl"), sort, + context.getRequiredPersistentEntity(Person.class)); + + assertThat(fields) // + .extracting(Objects::toString) // + .containsExactly("tbl.unknownField DESC"); + } + + @Test // GH-1507 + public void shouldMapSortWithAllowedSpecialCharacters() { + + Sort sort = Sort.by(desc("x(._)x")); + + List fields = mapper.getMappedSort(Table.create("tbl"), sort, + context.getRequiredPersistentEntity(Person.class)); + + assertThat(fields) // + .extracting(Objects::toString) // + .containsExactly("tbl.x(._)x DESC"); + } + + @ParameterizedTest // GH-1507 + @ValueSource(strings = { " ", ";", "--" }) + public void shouldNotMapSortWithIllegalExpression(String input) { + + Sort sort = Sort.by(desc("unknown" + input + "Field")); + + assertThatThrownBy( + () -> mapper.getMappedSort(Table.create("tbl"), sort, context.getRequiredPersistentEntity(Person.class))) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test // GH-1507 + public void shouldMapSortWithUnsafeExpression() { + + String unsafeExpression = "arbitrary expression that may include evil stuff like ; & --"; + Sort sort = SqlSort.unsafe(unsafeExpression); + + List fields = mapper.getMappedSort(Table.create("tbl"), sort, + context.getRequiredPersistentEntity(Person.class)); + + assertThat(fields) // + .extracting(Objects::toString) // + .containsExactly("tbl." + unsafeExpression + " ASC"); } private Condition map(Criteria criteria) { diff --git a/spring-data-r2dbc/src/main/java/org/springframework/data/r2dbc/query/QueryMapper.java b/spring-data-r2dbc/src/main/java/org/springframework/data/r2dbc/query/QueryMapper.java index d25c3a36..91d0a1b6 100644 --- a/spring-data-r2dbc/src/main/java/org/springframework/data/r2dbc/query/QueryMapper.java +++ b/spring-data-r2dbc/src/main/java/org/springframework/data/r2dbc/query/QueryMapper.java @@ -38,6 +38,7 @@ import org.springframework.data.relational.core.query.CriteriaDefinition; import org.springframework.data.relational.core.query.CriteriaDefinition.Comparator; import org.springframework.data.relational.core.query.ValueFunction; import org.springframework.data.relational.core.sql.*; +import org.springframework.data.relational.domain.SqlSort; import org.springframework.data.util.Pair; import org.springframework.data.util.TypeInformation; import org.springframework.lang.Nullable; @@ -56,6 +57,7 @@ import org.springframework.util.ClassUtils; * @author Mark Paluch * @author Roman Chigvintsev * @author Manousos Mathioudakis + * @author Jens Schauder */ public class QueryMapper { @@ -111,6 +113,8 @@ public class QueryMapper { for (Sort.Order order : sort) { + SqlSort.validate(order); + Field field = createPropertyField(entity, SqlIdentifier.unquoted(order.getProperty()), this.mappingContext); mappedOrder.add( Sort.Order.by(toSql(field.getMappedColumnName())).with(order.getNullHandling()).with(order.getDirection())); @@ -133,13 +137,22 @@ public class QueryMapper { for (Sort.Order order : sort) { - Field field = createPropertyField(entity, SqlIdentifier.unquoted(order.getProperty()), this.mappingContext); - OrderByField orderBy = OrderByField.from(table.column(field.getMappedColumnName())) + OrderByField simpleOrderByField = createSimpleOrderByField(table, entity, order); + OrderByField orderBy = simpleOrderByField .withNullHandling(order.getNullHandling()); mappedOrder.add(order.isAscending() ? orderBy.asc() : orderBy.desc()); } return mappedOrder; + + } + + private OrderByField createSimpleOrderByField(Table table, RelationalPersistentEntity entity, Sort.Order order) { + + SqlSort.validate(order); + + Field field = createPropertyField(entity, SqlIdentifier.unquoted(order.getProperty()), this.mappingContext); + return OrderByField.from(table.column(field.getMappedColumnName())); } /** diff --git a/spring-data-r2dbc/src/test/java/org/springframework/data/r2dbc/query/QueryMapperUnitTests.java b/spring-data-r2dbc/src/test/java/org/springframework/data/r2dbc/query/QueryMapperUnitTests.java index b283569d..144f941b 100644 --- a/spring-data-r2dbc/src/test/java/org/springframework/data/r2dbc/query/QueryMapperUnitTests.java +++ b/spring-data-r2dbc/src/test/java/org/springframework/data/r2dbc/query/QueryMapperUnitTests.java @@ -20,6 +20,8 @@ import static org.mockito.Mockito.*; import static org.springframework.data.domain.Sort.Order.*; import java.util.Collections; +import java.util.List; +import java.util.Objects; import org.junit.jupiter.api.Test; import org.springframework.core.convert.converter.Converter; @@ -35,6 +37,7 @@ import org.springframework.data.relational.core.mapping.Column; import org.springframework.data.relational.core.query.Criteria; import org.springframework.data.relational.core.sql.Expression; import org.springframework.data.relational.core.sql.Functions; +import org.springframework.data.relational.core.sql.OrderByField; import org.springframework.data.relational.core.sql.Table; import org.springframework.r2dbc.core.Parameter; import org.springframework.r2dbc.core.binding.BindMarkersFactory; @@ -47,6 +50,7 @@ import org.testcontainers.shaded.com.fasterxml.jackson.databind.node.TextNode; * * @author Mark Paluch * @author Mingyuan Wu + * @author Jens Schauder */ class QueryMapperUnitTests { @@ -423,6 +427,42 @@ class QueryMapperUnitTests { assertThat(mapped.getOrderFor("alternative_name")).isEqualTo(desc("alternative_name")); } + @Test // GH-1507 + public void shouldMapSortWithUnknownField() { + + Sort sort = Sort.by(desc("unknownField")); + + List fields = mapper.getMappedSort(Table.create("tbl"), sort, + mapper.getMappingContext().getRequiredPersistentEntity(Person.class)); + + assertThat(fields) // + .extracting(Objects::toString) // + .containsExactly("tbl.unknownField DESC"); + } + + @Test // GH-1507 + public void shouldMapSortWithAllowedSpecialCharacters() { + + Sort sort = Sort.by(desc("x(._)x")); + + List fields = mapper.getMappedSort(Table.create("tbl"), sort, + mapper.getMappingContext().getRequiredPersistentEntity(Person.class)); + + assertThat(fields) // + .extracting(Objects::toString) // + .containsExactly("tbl.x(._)x DESC"); + } + + + @Test // GH-1507 + public void shouldNotMapSortWithIllegalExpression() { + + Sort sort = Sort.by(desc("unknown Field")); + + assertThatThrownBy(() -> mapper.getMappedSort(Table.create("tbl"), sort, + mapper.getMappingContext().getRequiredPersistentEntity(Person.class))).isInstanceOf(IllegalArgumentException.class); + } + @Test // gh-369 void mapQueryForPropertyPathInPrimitiveShouldFallBackToColumnName() { diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/domain/SqlSort.java b/spring-data-relational/src/main/java/org/springframework/data/relational/domain/SqlSort.java new file mode 100644 index 00000000..72051add --- /dev/null +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/domain/SqlSort.java @@ -0,0 +1,293 @@ +/* + * Copyright 2023 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.data.relational.domain; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.function.Predicate; +import java.util.regex.Pattern; + +import org.springframework.data.domain.Sort; +import org.springframework.lang.Nullable; +import org.springframework.util.Assert; + +/** + * SqlSort supports additional to {@link Sort} {@literal unsafe} sort expressions. Such sort expressions get included in + * a query as they are. The user has to ensure that they come from trusted sorted or are properly sanatized to prevent + * SQL injection attacks. + * + * @author Jens Schauder + * @since 3.1 + */ +public class SqlSort extends Sort { + + private static final Predicate predicate = Pattern.compile("^[0-9a-zA-Z_\\.\\(\\)]*$").asPredicate(); + + private static final long serialVersionUID = 1L; + + private SqlSort(Direction direction, List paths) { + this(Collections. emptyList(), direction, paths); + } + + private SqlSort(List orders, @Nullable Direction direction, List paths) { + super(combine(orders, direction, paths)); + } + + private SqlSort(List orders) { + super(orders); + } + + /** + * @param paths must not be {@literal null} or empty. + */ + public static SqlSort of(String... paths) { + return new SqlSort(DEFAULT_DIRECTION, Arrays.asList(paths)); + } + + /** + * @param direction the sorting direction. + * @param paths must not be {@literal null} or empty. + */ + public static SqlSort of(Direction direction, String... paths) { + return new SqlSort(direction, Arrays.asList(paths)); + } + + /** + * Validates a {@link org.springframework.data.domain.Sort.Order}, to be either safe for use in SQL or to be + * explicitely marked unsafe. + * + * @param order the {@link org.springframework.data.domain.Sort.Order} to validate. Must not be null. + */ + public static void validate(Sort.Order order) { + + String property = order.getProperty(); + boolean isMarkedUnsafe = order instanceof SqlSort.SqlOrder ro && ro.isUnsafe(); + if (isMarkedUnsafe) { + return; + } + + if (!predicate.test(property)) { + throw new IllegalArgumentException( + "order fields that are not marked as unsafe must only consist of digits, letter, '.', '_', and '\'. If you want to sort by arbitrary expressions please use RelationalSort.unsafe. Note that such expressions become part of SQL statements and therefore need to be sanatized to prevent SQL injection attacks."); + } + } + + private static List combine(List orders, @Nullable Direction direction, List paths) { + + List result = new ArrayList<>(orders); + + for (String path : paths) { + result.add(new Order(direction, path)); + } + + return result; + } + + /** + * Creates new unsafe {@link SqlSort} based on given properties. + * + * @param properties must not be {@literal null} or empty. + * @return + */ + public static SqlSort unsafe(String... properties) { + return unsafe(Sort.DEFAULT_DIRECTION, properties); + } + + /** + * Creates new unsafe {@link SqlSort} based on given {@link Direction} and properties. + * + * @param direction must not be {@literal null}. + * @param properties must not be {@literal null} or empty. + * @return + */ + public static SqlSort unsafe(Direction direction, String... properties) { + + Assert.notNull(direction, "Direction must not be null"); + Assert.notEmpty(properties, "Properties must not be empty"); + Assert.noNullElements(properties, "Properties must not contain null values"); + + return unsafe(direction, Arrays.asList(properties)); + } + + /** + * Creates new unsafe {@link SqlSort} based on given {@link Direction} and properties. + * + * @param direction must not be {@literal null}. + * @param properties must not be {@literal null} or empty. + * @return + */ + public static SqlSort unsafe(Direction direction, List properties) { + + Assert.notEmpty(properties, "Properties must not be empty"); + + List orders = new ArrayList<>(properties.size()); + + for (String property : properties) { + orders.add(new SqlOrder(direction, property)); + } + + return new SqlSort(orders); + } + + /** + * Returns a new {@link SqlSort} with the given sorting criteria added to the current one. + * + * @param direction can be {@literal null}. + * @param paths must not be {@literal null}. + * @return + */ + public SqlSort and(@Nullable Direction direction, String... paths) { + + Assert.notNull(paths, "Paths must not be null"); + + List existing = new ArrayList<>(); + + for (Order order : this) { + existing.add(order); + } + + return new SqlSort(existing, direction, Arrays.asList(paths)); + } + + /** + * Returns a new {@link SqlSort} with the given sorting criteria added to the current one. + * + * @param direction can be {@literal null}. + * @param properties must not be {@literal null} or empty. + * @return + */ + public SqlSort andUnsafe(@Nullable Direction direction, String... properties) { + + Assert.notEmpty(properties, "Properties must not be empty"); + + List orders = new ArrayList<>(); + + for (Order order : this) { + orders.add(order); + } + + for (String property : properties) { + orders.add(new SqlOrder(direction, property)); + } + + return new SqlSort(orders, direction, Collections.emptyList()); + } + + /** + * Custom {@link Order} that keeps a flag to indicate unsafe property handling, i.e. the String provided is not + * necessarily a property but can be an arbitrary expression piped into the query execution. We also keep an + * additional {@code ignoreCase} flag around as the constructor of the superclass is private currently. + * + * @author Christoph Strobl + * @author Oliver Gierke + */ + public static class SqlOrder extends Order { + + private static final long serialVersionUID = 1L; + + private final boolean unsafe; + + /** + * Creates a new {@link SqlOrder} instance. Takes a single property. Direction defaults to + * {@link Sort#DEFAULT_DIRECTION}. + * + * @param property must not be {@literal null} or empty. + */ + public static SqlOrder by(String property) { + return new SqlOrder(DEFAULT_DIRECTION, property); + } + + /** + * Creates a new {@link SqlOrder} instance. Takes a single property. Direction is {@link Direction#ASC} and + * NullHandling {@link NullHandling#NATIVE}. + * + * @param property must not be {@literal null} or empty. + */ + public static SqlOrder asc(String property) { + return new SqlOrder(Direction.ASC, property, NullHandling.NATIVE); + } + + /** + * Creates a new {@link SqlOrder} instance. Takes a single property. Direction is {@link Direction#DESC} and + * NullHandling {@link NullHandling#NATIVE}. + * + * @param property must not be {@literal null} or empty. + */ + public static SqlOrder desc(String property) { + return new SqlOrder(Direction.DESC, property, NullHandling.NATIVE); + } + + /** + * Creates a new {@link SqlOrder} instance. if order is {@literal null} then order defaults to + * {@link Sort#DEFAULT_DIRECTION} + * + * @param direction can be {@literal null}, will default to {@link Sort#DEFAULT_DIRECTION}. + * @param property must not be {@literal null}. + */ + private SqlOrder(@Nullable Direction direction, String property) { + this(direction, property, NullHandling.NATIVE); + } + + /** + * Creates a new {@link SqlOrder} instance. if order is {@literal null} then order defaults to + * {@link Sort#DEFAULT_DIRECTION}. + * + * @param direction can be {@literal null}, will default to {@link Sort#DEFAULT_DIRECTION}. + * @param property must not be {@literal null}. + * @param nullHandlingHint can be {@literal null}, will default to {@link NullHandling#NATIVE}. + */ + private SqlOrder(@Nullable Direction direction, String property, NullHandling nullHandlingHint) { + this(direction, property, nullHandlingHint, false, true); + } + + private SqlOrder(@Nullable Direction direction, String property, NullHandling nullHandling, boolean ignoreCase, + boolean unsafe) { + + super(direction, property, ignoreCase, nullHandling); + this.unsafe = unsafe; + } + + @Override + public SqlOrder with(Direction order) { + return new SqlOrder(order, getProperty(), getNullHandling(), isIgnoreCase(), isUnsafe()); + } + + @Override + public SqlOrder with(NullHandling nullHandling) { + return new SqlOrder(getDirection(), getProperty(), nullHandling, isIgnoreCase(), isUnsafe()); + } + + public SqlOrder withUnsafe() { + return new SqlOrder(getDirection(), getProperty(), getNullHandling(), isIgnoreCase(), true); + } + + @Override + public SqlOrder ignoreCase() { + return new SqlOrder(getDirection(), getProperty(), getNullHandling(), true, isUnsafe()); + } + + /** + * @return true if {@link SqlOrder} should not be validated automatically. The validation should be done by the + * developer using this. + */ + public boolean isUnsafe() { + return unsafe; + } + } +} diff --git a/spring-data-relational/src/test/java/org/springframework/data/relational/domain/SqlSortUnitTests.java b/spring-data-relational/src/test/java/org/springframework/data/relational/domain/SqlSortUnitTests.java new file mode 100644 index 00000000..db4a2bb9 --- /dev/null +++ b/spring-data-relational/src/test/java/org/springframework/data/relational/domain/SqlSortUnitTests.java @@ -0,0 +1,86 @@ +/* + * Copyright 2023 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.data.relational.domain; + +import static org.assertj.core.api.Assertions.*; + +import org.junit.jupiter.api.Test; +import org.springframework.data.domain.Sort; + +/** + * Unit tests for {@link SqlSort} and + * {@link SqlSort.SqlOrder}. + * + * @author Jens Schauder + */ +class SqlSortUnitTests { + + @Test + void sortOfDirectionAndProperties() { + + SqlSort sort = SqlSort.of(Sort.Direction.DESC, "firstName", "lastName"); + + assertThat(sort).containsExactly( // + SqlSort.SqlOrder.desc("firstName"), // + SqlSort.SqlOrder.desc("lastName") // + ); + } + + @Test + void unsafeSortOfProperties() { + + SqlSort sort = SqlSort.unsafe("firstName", "lastName"); + + assertThat(sort).containsExactly( // + SqlSort.SqlOrder.by("firstName"), // + SqlSort.SqlOrder.by("lastName") // + ); + } + + @Test + void mixingDirections() { + + SqlSort sort = SqlSort.of("firstName").and(Sort.Direction.DESC, "lastName", "address"); + + assertThat(sort).containsExactly( // + SqlSort.SqlOrder.asc("firstName"), // + SqlSort.SqlOrder.desc("lastName"), // + SqlSort.SqlOrder.desc("address") // + ); + } + + @Test + void mixingDirectionsAndSafety() { + + SqlSort sort = SqlSort.of("firstName").andUnsafe(Sort.Direction.DESC, "lastName", "address"); + + assertThat(sort).containsExactly( // + SqlSort.SqlOrder.by("firstName"), // + SqlSort.SqlOrder.desc("lastName").withUnsafe(), // + SqlSort.SqlOrder.desc("address").withUnsafe() // + ); + } + + @Test + void orderDoesNotDependOnOrderOfMethodCalls() { + + assertThat( + SqlSort.SqlOrder.desc("property").ignoreCase().withUnsafe().with(Sort.NullHandling.NULLS_LAST)) + .isEqualTo(SqlSort.SqlOrder.by("property").with(Sort.NullHandling.NULLS_LAST).withUnsafe() + .ignoreCase().with(Sort.Direction.DESC)); + } +}