Skip to content

Implemented Postgres-specific array Criteria operations #1981

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@
* @author Oliver Drotbohm
* @author Roman Chigvintsev
* @author Jens Schauder
* @author Mikhail Polivakha
* @since 2.0
*/
public class Criteria implements CriteriaDefinition {
Expand All @@ -68,7 +69,7 @@ public class Criteria implements CriteriaDefinition {
private final @Nullable Object value;
private final boolean ignoreCase;

private Criteria(SqlIdentifier column, Comparator comparator, @Nullable Object value) {
public Criteria(SqlIdentifier column, @Nullable Comparator comparator, @Nullable Object value) {
this(null, Combinator.INITIAL, Collections.emptyList(), column, comparator, value, false);
}

Expand Down Expand Up @@ -328,13 +329,15 @@ private boolean doIsEmpty() {
/**
* @return {@literal true} if this {@link Criteria} is empty.
*/
@Override
public boolean isGroup() {
return !this.group.isEmpty();
}

/**
* @return {@link Combinator} to combine this criteria with a previous one.
*/
@Override
public Combinator getCombinator() {
return combinator;
}
Expand Down Expand Up @@ -515,6 +518,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);
}
Expand Down Expand Up @@ -653,6 +660,12 @@ public interface CriteriaStep {
* @return a new {@link Criteria} object
*/
Criteria isFalse();

}

static interface CriteriaLiteral {

String getLiteral();
}

/**
Expand Down Expand Up @@ -812,7 +825,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);
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
*
* @author Mark Paluch
* @author Jens Schauder
* @author Mikhail Polivakha
* @since 2.0
*/
public interface CriteriaDefinition {
Expand Down Expand Up @@ -140,7 +141,7 @@ enum Combinator {
enum Comparator {
INITIAL(""), EQ("="), NEQ("!="), BETWEEN("BETWEEN"), NOT_BETWEEN("NOT BETWEEN"), LT("<"), LTE("<="), GT(">"), GTE(
">="), IS_NULL("IS NULL"), IS_NOT_NULL("IS NOT NULL"), LIKE(
"LIKE"), NOT_LIKE("NOT LIKE"), NOT_IN("NOT IN"), IN("IN"), IS_TRUE("IS TRUE"), IS_FALSE("IS FALSE");
"LIKE"), NOT_LIKE("NOT LIKE"), NOT_IN("NOT IN"), IN("IN"), IS_TRUE("IS TRUE"), IS_FALSE("IS FALSE"), ARRAY_CONTAINS("@>");
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's keep the comparator enum as-is. It's closed for extension. Any comparators should come really from inside of the extension.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I have thought about it too. I'll thinkg about the way to solve it, I'll brb.


private final String comparator;

Expand Down
Original file line number Diff line number Diff line change
@@ -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);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
/*
* 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.*;

import java.sql.JDBCType;
import java.util.StringJoiner;

import org.jetbrains.annotations.NotNull;
import org.springframework.core.ResolvableType;
import org.springframework.data.relational.core.sql.SqlIdentifier;
import org.springframework.data.util.TypeInformation;
import org.springframework.util.Assert;

/**
* PostgreSQL-specific {@link Criteria} conditions.
*
* @author Mikhail Polivakha
*/
public class Postgres {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the way to go, with fluent API and something that guides towards the final operator. We have a similar setup in MongoDB's aggregation operators.


/**
* 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:
* <p>
* <pre class="code">
* // Code below produces the SQL: "my_column" @> ARRAY['A', 'B']
* Postgres.whereArray("my_column").contains("A", "B")
* </pre>
* Code above produces the SQL:
* <pre class="code">
* "my_column" @> ARRAY['A', 'B']
* </pre>
*
* @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) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have not a good feeling yet for the return type. CriteriaDefinition is rather broadly fixed to groups, lhs column, comparator and value and some navigation across criteria. I think we need some super-interface that makes less assumptions.

I need to explore this aspect in much more depth.

Copy link
Contributor Author

@mipo256 mipo256 Apr 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Okay, but as was stated here:

Let's however start from a simpler side first so that we can implement a first proof of concept before digging into the more complex things. It is typically easier to build something (starting from the side how things should be expressed in code) and then we can iterate on it towards a variant that handles an increasing number of usecases.

Maybe we can just try this solution and do not introduce another abstractions? I absolutely share your concern, @mp911de, because the pattern:

  • LHS column
  • Operator
  • value

is not always the case. So, I do not know... Maybe we can indeed leave it for now.

Assert.notNull(values, "values array cannot be null");

return new Criteria(SqlIdentifier.quoted(arrayColumnName), CriteriaDefinition.Comparator.ARRAY_CONTAINS, 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 <T> 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();
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -30,6 +31,7 @@
* @author Mark Paluch
* @author Jens Schauder
* @author Roman Chigvintsev
* @author Mikhail Polivakha
*/
class CriteriaUnitTests {

Expand Down Expand Up @@ -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() {

Expand All @@ -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() {

Expand Down Expand Up @@ -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()).isEqualTo(Comparator.ARRAY_CONTAINS);
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() {

Expand Down
Loading