diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/dialect/OracleDialect.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/dialect/OracleDialect.java index 7f65461093..d995673436 100644 --- a/spring-data-relational/src/main/java/org/springframework/data/relational/core/dialect/OracleDialect.java +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/dialect/OracleDialect.java @@ -28,7 +28,7 @@ * An SQL dialect for Oracle. * * @author Jens Schauder - * @author Mikahil Polivakha + * @author Mikhail Polivakha * @since 2.1 */ public class OracleDialect extends AnsiDialect { diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/dialect/PostgresDialect.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/dialect/PostgresDialect.java index 6979c365e9..2bf62bcd76 100644 --- a/spring-data-relational/src/main/java/org/springframework/data/relational/core/dialect/PostgresDialect.java +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/dialect/PostgresDialect.java @@ -57,6 +57,7 @@ public class PostgresDialect extends AbstractDialect { private IdentifierProcessing identifierProcessing = IdentifierProcessing.create(Quoting.ANSI, LetterCasing.LOWER_CASE); + private IdGeneration idGeneration = new IdGeneration() { @Override diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/query/Criteria.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/query/Criteria.java index 2b2deff2f2..d727f8f44b 100644 --- a/spring-data-relational/src/main/java/org/springframework/data/relational/core/query/Criteria.java +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/query/Criteria.java @@ -53,6 +53,7 @@ * @author Oliver Drotbohm * @author Roman Chigvintsev * @author Jens Schauder + * @author Mikhail Polivakha * @since 2.0 */ public class Criteria implements CriteriaDefinition { @@ -65,26 +66,32 @@ public class Criteria implements CriteriaDefinition { private final @Nullable SqlIdentifier column; private final @Nullable Comparator comparator; + private final @Nullable ExtendedComparator extendedComparator; private final @Nullable Object value; private final boolean ignoreCase; - private Criteria(SqlIdentifier column, Comparator comparator, @Nullable Object value) { - this(null, Combinator.INITIAL, Collections.emptyList(), column, comparator, value, false); + Criteria(SqlIdentifier column, @Nullable Comparator comparator, @Nullable Object value) { + this(null, Combinator.INITIAL, Collections.emptyList(), column, comparator, null, value, false); + } + + Criteria(SqlIdentifier column, ExtendedComparator extendedComparator, @Nullable Object value) { + this(null, Combinator.INITIAL, Collections.emptyList(), column, null, extendedComparator, value, false); } private Criteria(@Nullable Criteria previous, Combinator combinator, List group, @Nullable SqlIdentifier column, @Nullable Comparator comparator, @Nullable Object value) { - this(previous, combinator, group, column, comparator, value, false); + this(previous, combinator, group, column, comparator, null, value, false); } private Criteria(@Nullable Criteria previous, Combinator combinator, List group, - @Nullable SqlIdentifier column, @Nullable Comparator comparator, @Nullable Object value, boolean ignoreCase) { + @Nullable SqlIdentifier column, @Nullable Comparator comparator, @Nullable ExtendedComparator extendedComparator, @Nullable Object value, boolean ignoreCase) { this.previous = previous; this.combinator = previous != null && previous.isEmpty() ? Combinator.INITIAL : combinator; this.group = group; this.column = column; this.comparator = comparator; + this.extendedComparator = extendedComparator; this.value = value; this.ignoreCase = ignoreCase; } @@ -96,6 +103,7 @@ private Criteria(@Nullable Criteria previous, Combinator combinator, List criteria) { */ public Criteria ignoreCase(boolean ignoreCase) { if (this.ignoreCase != ignoreCase) { - return new Criteria(previous, combinator, group, column, comparator, value, ignoreCase); + return new Criteria(previous, combinator, group, column, comparator, extendedComparator, value, ignoreCase); } return this; } @@ -328,6 +336,7 @@ private boolean doIsEmpty() { /** * @return {@literal true} if this {@link Criteria} is empty. */ + @Override public boolean isGroup() { return !this.group.isEmpty(); } @@ -335,6 +344,7 @@ public boolean isGroup() { /** * @return {@link Combinator} to combine this criteria with a previous one. */ + @Override public Combinator getCombinator() { return combinator; } @@ -360,6 +370,11 @@ public Comparator getComparator() { return comparator; } + @Override + public ExtendedComparator getExtendedComparator() { + return extendedComparator; + } + /** * @return the comparison value. Can be {@literal null}. */ @@ -405,12 +420,13 @@ public boolean equals(Object o) { && Objects.equals(group, criteria.group) // && Objects.equals(column, criteria.column) // && comparator == criteria.comparator // + && extendedComparator == criteria.extendedComparator // && Objects.equals(value, criteria.value); } @Override public int hashCode() { - return Objects.hash(previous, combinator, group, column, comparator, value, ignoreCase); + return Objects.hash(previous, combinator, group, column, comparator, extendedComparator, value, ignoreCase); } private void unroll(CriteriaDefinition criteria, StringBuilder stringBuilder) { @@ -476,29 +492,35 @@ private void render(CriteriaDefinition criteria, StringBuilder stringBuilder) { return; } - stringBuilder.append(criteria.getColumn().toSql(IdentifierProcessing.NONE)).append(' ') - .append(criteria.getComparator().getComparator()); + stringBuilder.append(criteria.getColumn().toSql(IdentifierProcessing.NONE)).append(' '); + + if (criteria.getExtendedComparator() != null) { + stringBuilder.append(criteria.getExtendedComparator().operator()).append(' ').append(renderValue(criteria.getValue())); + } else { - switch (criteria.getComparator()) { - case BETWEEN: - case NOT_BETWEEN: - Pair pair = (Pair) criteria.getValue(); - stringBuilder.append(' ').append(pair.getFirst()).append(" AND ").append(pair.getSecond()); - break; + stringBuilder.append(criteria.getComparator().getComparator()); - case IS_NULL: - case IS_NOT_NULL: - case IS_TRUE: - case IS_FALSE: - break; + switch (criteria.getComparator()) { + case BETWEEN: + case NOT_BETWEEN: + Pair pair = (Pair) criteria.getValue(); + stringBuilder.append(' ').append(pair.getFirst()).append(" AND ").append(pair.getSecond()); + break; - case IN: - case NOT_IN: - stringBuilder.append(" (").append(renderValue(criteria.getValue())).append(')'); - break; + case IS_NULL: + case IS_NOT_NULL: + case IS_TRUE: + case IS_FALSE: + break; - default: - stringBuilder.append(' ').append(renderValue(criteria.getValue())); + case IN: + case NOT_IN: + stringBuilder.append(" (").append(renderValue(criteria.getValue())).append(')'); + break; + + default: + stringBuilder.append(' ').append(renderValue(criteria.getValue())); + } } } @@ -515,6 +537,10 @@ private static String renderValue(@Nullable Object value) { return joiner.toString(); } + if (value instanceof CriteriaLiteral literal) { + return literal.getLiteral(); + } + if (value != null) { return String.format("'%s'", value); } @@ -653,6 +679,12 @@ public interface CriteriaStep { * @return a new {@link Criteria} object */ Criteria isFalse(); + + } + + static interface CriteriaLiteral { + + String getLiteral(); } /** @@ -812,7 +844,7 @@ public Criteria isFalse() { return createCriteria(Comparator.IS_FALSE, false); } - protected Criteria createCriteria(Comparator comparator, @Nullable Object value) { + protected Criteria createCriteria(@Nullable Comparator comparator, @Nullable Object value) { return new Criteria(this.property, comparator, value); } } diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/query/CriteriaDefinition.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/query/CriteriaDefinition.java index c09129a1b6..941469bb3d 100644 --- a/spring-data-relational/src/main/java/org/springframework/data/relational/core/query/CriteriaDefinition.java +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/query/CriteriaDefinition.java @@ -28,6 +28,7 @@ * * @author Mark Paluch * @author Jens Schauder + * @author Mikhail Polivakha * @since 2.0 */ public interface CriteriaDefinition { @@ -97,6 +98,12 @@ static CriteriaDefinition from(List criteria) { @Nullable Comparator getComparator(); + /** + * @return {@link ExtendedComparator}. + */ + @Nullable + ExtendedComparator getExtendedComparator(); + /** * @return the comparison value. Can be {@literal null}. */ diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/query/ExtendedComparator.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/query/ExtendedComparator.java new file mode 100644 index 0000000000..32039d0494 --- /dev/null +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/query/ExtendedComparator.java @@ -0,0 +1,43 @@ +/* + * Copyright 2020-2025 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.core.query; + +/** + * Analog of {@link org.springframework.data.relational.core.query.CriteriaDefinition.Comparator} for the extended operators, + * that are not commonly supported by RDBMS vendors. + * + * @author Mikhail Polivakha + */ +interface ExtendedComparator { + + String operator(); + + /** + * PostgreSQL specific operator for checking if the SQL ARRAY contains the given sub-array. + * + * @author Mikhail Polivakha + */ + enum PostgresExtendedContains implements ExtendedComparator { + + INSTANCE; + + @Override + public String operator() { + return "@>"; + } + } +} diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/query/OngoingArrayCriteria.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/query/OngoingArrayCriteria.java new file mode 100644 index 0000000000..4a765fe2b4 --- /dev/null +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/query/OngoingArrayCriteria.java @@ -0,0 +1,34 @@ +/* + * Copyright 2020-2025 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.core.query; + +/** + * An Ongoing Criteria builder for {@link java.sql.Types#ARRAY SQL Array} related operations. + * Used by intermediate builder objects returned from the {@link Criteria}. + * + * @author Mikhail Polivakha + */ +public interface OngoingArrayCriteria { + + /** + * Builds a {@link Criteria} where the pre-defined array must contain given values. + * + * @param values values to be present in the array + * @return built {@link Criteria} + */ + Criteria contains(Object... values); +} diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/query/Postgres.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/query/Postgres.java new file mode 100644 index 0000000000..82d56abb0c --- /dev/null +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/query/Postgres.java @@ -0,0 +1,102 @@ +/* + * Copyright 2020-2025 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.core.query; + +import static org.springframework.data.relational.core.query.Criteria.CriteriaLiteral; + +import java.sql.JDBCType; +import java.util.StringJoiner; + +import org.jetbrains.annotations.NotNull; +import org.springframework.data.relational.core.sql.SqlIdentifier; +import org.springframework.util.Assert; + +/** + * PostgreSQL-specific {@link Criteria} conditions. + * + * @author Mikhail Polivakha + */ +public class Postgres { + + /** + * Custom {@link Criteria} condition builder to check if the column of an {@link java.sql.Types#ARRAY ARRAY} sql type + * matches specific conditions. Samples of usage is: + *

+ *

+	 * // Code below produces the SQL: "my_column" @> ARRAY['A', 'B']
+	 * Postgres.whereArray("my_column").contains("A", "B")
+	 * 
+ * Code above produces the SQL: + *
+	 *   "my_column" @> ARRAY['A', 'B']
+	 * 
+ * + * @param arrayName the name of an ARRAY column to match against + * @return the {@link OngoingArrayCriteria} to chain future condition + */ + public static OngoingArrayCriteria array(String arrayName) { + return new PostgresCriteriaArray(arrayName); + } + + public static class PostgresCriteriaArray implements OngoingArrayCriteria { + + private final String arrayColumnName; + + public PostgresCriteriaArray(String arrayColumnName) { + this.arrayColumnName = arrayColumnName; + } + + @NotNull + @Override + public Criteria + contains(Object... values) { + Assert.notNull(values, "values array cannot be null"); + + return new Criteria(SqlIdentifier.quoted(arrayColumnName), ExtendedComparator.PostgresExtendedContains.INSTANCE, new CriteriaLiteral() { + + @Override + public String getLiteral() { + boolean quoted = true; + + if (values.length > 0) { + quoted = !Number.class.isAssignableFrom(values[0].getClass()); + } + + return toArrayLiteral(quoted, values); + } + }); + } + + @SafeVarargs + public final String toArrayLiteral(boolean quoted, T... values) { + StringJoiner accumulator = new StringJoiner(",", "ARRAY[", "]"); + + for (T value : values) { + if (value != null) { + if (quoted) { + accumulator.add("'" + value + "'"); + } else { + accumulator.add(value.toString()); + } + } else { + accumulator.add(JDBCType.NULL.name()); + } + } + return accumulator.toString(); + } + } +} diff --git a/spring-data-relational/src/test/java/org/springframework/data/relational/core/query/CriteriaUnitTests.java b/spring-data-relational/src/test/java/org/springframework/data/relational/core/query/CriteriaUnitTests.java index d107c67e72..57888cfc07 100644 --- a/spring-data-relational/src/test/java/org/springframework/data/relational/core/query/CriteriaUnitTests.java +++ b/spring-data-relational/src/test/java/org/springframework/data/relational/core/query/CriteriaUnitTests.java @@ -21,6 +21,7 @@ import java.util.Arrays; +import org.assertj.core.api.InstanceOfAssertFactories; import org.junit.jupiter.api.Test; import org.springframework.data.relational.core.sql.SqlIdentifier; @@ -30,6 +31,7 @@ * @author Mark Paluch * @author Jens Schauder * @author Roman Chigvintsev + * @author Mikhail Polivakha */ class CriteriaUnitTests { @@ -95,6 +97,27 @@ void andChainedCriteria() { assertThat(criteria.getValue()).isEqualTo("bar"); } + @Test // DATAJDBC-513 + void andChainedCriteriaWithDialectCriteriaCondition() { + + Criteria criteria = where("foo").is("bar").and(Postgres.array("baz").contains("first", "second")); + var previous = criteria.getPrevious(); + + assertSoftly(softAssertions -> { + softAssertions.assertThat(criteria.getGroup().get(0).getColumn()).isEqualTo(SqlIdentifier.quoted("baz")); + softAssertions.assertThat(criteria.getComparator()).isNull(); + softAssertions.assertThat(criteria.getValue()).isNull(); + softAssertions.assertThat(criteria.getPrevious()).isNotNull(); + softAssertions.assertThat(criteria.getCombinator()).isEqualTo(Criteria.Combinator.AND); + softAssertions.assertThat(criteria.toString()).isEqualTo("foo = 'bar' AND (baz @> ARRAY['first','second'])"); + + softAssertions.assertThat(previous.getColumn()).isEqualTo(SqlIdentifier.unquoted("foo")); + softAssertions.assertThat(previous.getComparator()).isEqualTo(CriteriaDefinition.Comparator.EQ); + softAssertions.assertThat(previous.getValue()).isEqualTo("bar"); + softAssertions.assertThat(previous.toString()).isEqualTo("foo = 'bar'"); + }); + } + @Test // DATAJDBC-513 void andGroupedCriteria() { @@ -116,6 +139,26 @@ void andGroupedCriteria() { assertThat(grouped).hasToString("foo = 'bar' AND (foo = 'baz' OR bar IS NOT NULL)"); } + @Test // DATAJDBC-1953 + void andGroupedCriteriaWithDialectCriteriaCondition() { + + Criteria grouped = where("foo").is("bar").and(where("foo").is("baz").or(Postgres.array("bar").contains("electronics"))); + Criteria previous = grouped.getPrevious(); + + assertSoftly(softAssertions -> { + softAssertions.assertThat(grouped.isGroup()).isTrue(); + softAssertions.assertThat(grouped.getGroup()).hasSize(1); + softAssertions.assertThat(grouped.getGroup().get(0).getGroup().get(0).getColumn()).isEqualTo(SqlIdentifier.unquoted("\"bar\"")); + softAssertions.assertThat(grouped.getCombinator()).isEqualTo(Criteria.Combinator.AND); + softAssertions.assertThat(grouped).hasToString("foo = 'bar' AND (foo = 'baz' OR (bar @> ARRAY['electronics']))"); + + softAssertions.assertThat(previous).isNotNull(); + softAssertions.assertThat(previous.getColumn()).isEqualTo(SqlIdentifier.unquoted("foo")); + softAssertions.assertThat(previous.getComparator()).isEqualTo(CriteriaDefinition.Comparator.EQ); + softAssertions.assertThat(previous.getValue()).isEqualTo("bar"); + }); + } + @Test // DATAJDBC-513 void orChainedCriteria() { @@ -179,6 +222,25 @@ void shouldBuildNotEqualsCriteria() { assertThat(criteria.getValue()).isEqualTo("bar"); } + @Test // DATAJDBC-1953 + void shouldBuildSimplePredefinedDialectCriteriaCondition() { + + Object[] values = { 1, 2, 3 }; + Criteria criteria = Postgres.array("foo").contains(values); + + assertSoftly(softAssertions -> { + softAssertions.assertThat(criteria.getColumn()).isEqualTo(SqlIdentifier.quoted("foo")); + softAssertions.assertThat(criteria.getComparator()).isNull(); + softAssertions.assertThat(criteria.getValue()).isInstanceOf(CriteriaLiteral.class); + softAssertions + .assertThat(criteria.getValue()) + .asInstanceOf(InstanceOfAssertFactories.type(CriteriaLiteral.class)) + .extracting(CriteriaLiteral::getLiteral) + .isEqualTo("ARRAY[1,2,3]"); + softAssertions.assertThat(criteria.toString()).isEqualTo("foo @> ARRAY[1,2,3]"); + }); + } + @Test // DATAJDBC-513 void shouldBuildInCriteria() {