From 217c0ea69aa514e4a4d92031b97aa0e3c68f3ab6 Mon Sep 17 00:00:00 2001 From: Mark Paluch Date: Wed, 18 Jun 2025 14:06:39 +0200 Subject: [PATCH 1/8] Prepare issue branch. --- pom.xml | 2 +- spring-data-jdbc-distribution/pom.xml | 2 +- spring-data-jdbc/pom.xml | 4 ++-- spring-data-r2dbc/pom.xml | 4 ++-- spring-data-relational/pom.xml | 4 ++-- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/pom.xml b/pom.xml index eaee823fa0..ee1ab2b84d 100644 --- a/pom.xml +++ b/pom.xml @@ -7,7 +7,7 @@ org.springframework.data spring-data-relational-parent - 4.0.0-SNAPSHOT + 4.0.0-GH-1953-SNAPSHOT pom Spring Data Relational Parent diff --git a/spring-data-jdbc-distribution/pom.xml b/spring-data-jdbc-distribution/pom.xml index b3c39e64c3..cef08fe2e4 100644 --- a/spring-data-jdbc-distribution/pom.xml +++ b/spring-data-jdbc-distribution/pom.xml @@ -14,7 +14,7 @@ org.springframework.data spring-data-relational-parent - 4.0.0-SNAPSHOT + 4.0.0-GH-1953-SNAPSHOT ../pom.xml diff --git a/spring-data-jdbc/pom.xml b/spring-data-jdbc/pom.xml index e61fd64020..5df3ffd13f 100644 --- a/spring-data-jdbc/pom.xml +++ b/spring-data-jdbc/pom.xml @@ -6,7 +6,7 @@ 4.0.0 spring-data-jdbc - 4.0.0-SNAPSHOT + 4.0.0-GH-1953-SNAPSHOT Spring Data JDBC Spring Data module for JDBC repositories. @@ -15,7 +15,7 @@ org.springframework.data spring-data-relational-parent - 4.0.0-SNAPSHOT + 4.0.0-GH-1953-SNAPSHOT diff --git a/spring-data-r2dbc/pom.xml b/spring-data-r2dbc/pom.xml index 3ee76fd3c1..17db8c9de7 100644 --- a/spring-data-r2dbc/pom.xml +++ b/spring-data-r2dbc/pom.xml @@ -6,7 +6,7 @@ 4.0.0 spring-data-r2dbc - 4.0.0-SNAPSHOT + 4.0.0-GH-1953-SNAPSHOT Spring Data R2DBC Spring Data module for R2DBC @@ -15,7 +15,7 @@ org.springframework.data spring-data-relational-parent - 4.0.0-SNAPSHOT + 4.0.0-GH-1953-SNAPSHOT diff --git a/spring-data-relational/pom.xml b/spring-data-relational/pom.xml index 8fd6d7a6f0..82c6a333f7 100644 --- a/spring-data-relational/pom.xml +++ b/spring-data-relational/pom.xml @@ -6,7 +6,7 @@ 4.0.0 spring-data-relational - 4.0.0-SNAPSHOT + 4.0.0-GH-1953-SNAPSHOT Spring Data Relational Spring Data Relational support @@ -14,7 +14,7 @@ org.springframework.data spring-data-relational-parent - 4.0.0-SNAPSHOT + 4.0.0-GH-1953-SNAPSHOT From 33775328ddaef46c69847955261a9cf69eafcb18 Mon Sep 17 00:00:00 2001 From: Mark Paluch Date: Mon, 23 Jun 2025 16:27:28 +0200 Subject: [PATCH 2/8] Hacking. --- .../core/convert/MappingJdbcConverter.java | 27 ++ .../core/binding/AnonymousBindMarkers.java | 59 +++ .../relational/core/binding/BindMarkers.java | 53 +++ .../core/binding/BindMarkersFactory.java | 118 ++++++ .../core/binding/IndexedBindMarkers.java | 60 +++ .../core/binding/NamedBindMarkers.java | 76 ++++ .../core/dialect/PostgresDialect.java | 2 +- .../data/relational/core/query/Bindings.java | 25 ++ .../data/relational/core/query/Criteria.java | 41 +- .../core/query/CriteriaContext.java | 35 ++ .../core/query/CriteriaDefinition.java | 3 + .../relational/core/query/CriteriaSource.java | 33 ++ .../core/query/CriteriaSources.java | 50 +++ .../core/query/NestedQueryExpression.java | 47 +++ .../data/relational/core/query/PgSql.java | 353 ++++++++++++++++++ .../data/relational/core/query/PgSqlImpl.java | 347 +++++++++++++++++ .../core/query/QueryExpression.java | 89 +++++ .../core/query/SimpleQueryRenderContext.java | 282 ++++++++++++++ .../relational/core/sql/AndCondition.java | 2 +- .../core/sql/ArrayIndexExpression.java | 37 ++ .../relational/core/sql/BaseFunction.java | 137 +++++++ .../data/relational/core/sql/BindMarker.java | 70 ++++ .../data/relational/core/sql/Comparison.java | 29 +- .../data/relational/core/sql/Condition.java | 8 +- .../relational/core/sql/ConditionWrapper.java | 39 ++ .../relational/core/sql/DefaultSelect.java | 2 +- .../core/sql/DefaultSelectBuilder.java | 23 +- .../data/relational/core/sql/Disjunct.java | 2 +- .../data/relational/core/sql/Expressions.java | 5 + .../core/sql/MultipleCondition.java | 6 +- .../core/sql/MultipleExpression.java | 53 +++ .../relational/core/sql/NestedCondition.java | 4 +- .../relational/core/sql/NestedExpression.java | 34 ++ .../core/sql/OperatorExpression.java | 108 ++++++ .../data/relational/core/sql/OrCondition.java | 2 +- .../core/sql/PostfixExpression.java | 44 +++ .../relational/core/sql/SelectBuilder.java | 15 +- .../relational/core/sql/SimpleFunction.java | 77 +--- .../relational/core/sql/Unrestricted.java | 8 +- .../data/relational/core/sql/Where.java | 4 +- .../sql/render/AnalyticFunctionVisitor.java | 8 +- .../render/ArrayIndexExpressionVisitor.java | 63 ++++ .../core/sql/render/ConditionVisitor.java | 16 +- .../core/sql/render/ExpressionVisitor.java | 31 +- ...ctionVisitor.java => FunctionVisitor.java} | 14 +- ...itor.java => NestedExpressionVisitor.java} | 56 ++- ...or.java => OperatorExpressionVisitor.java} | 46 ++- .../core/sql/render/OrderByClauseVisitor.java | 10 +- .../sql/render/PostfixExpressionVisitor.java | 64 ++++ .../core/sql/render/SelectListVisitor.java | 14 +- .../relational/core/query/PgSqlUnitTests.java | 116 ++++++ 51 files changed, 2660 insertions(+), 187 deletions(-) create mode 100644 spring-data-relational/src/main/java/org/springframework/data/relational/core/binding/AnonymousBindMarkers.java create mode 100644 spring-data-relational/src/main/java/org/springframework/data/relational/core/binding/BindMarkers.java create mode 100644 spring-data-relational/src/main/java/org/springframework/data/relational/core/binding/BindMarkersFactory.java create mode 100644 spring-data-relational/src/main/java/org/springframework/data/relational/core/binding/IndexedBindMarkers.java create mode 100644 spring-data-relational/src/main/java/org/springframework/data/relational/core/binding/NamedBindMarkers.java create mode 100644 spring-data-relational/src/main/java/org/springframework/data/relational/core/query/Bindings.java create mode 100644 spring-data-relational/src/main/java/org/springframework/data/relational/core/query/CriteriaContext.java create mode 100644 spring-data-relational/src/main/java/org/springframework/data/relational/core/query/CriteriaSource.java create mode 100644 spring-data-relational/src/main/java/org/springframework/data/relational/core/query/CriteriaSources.java create mode 100644 spring-data-relational/src/main/java/org/springframework/data/relational/core/query/NestedQueryExpression.java create mode 100644 spring-data-relational/src/main/java/org/springframework/data/relational/core/query/PgSql.java create mode 100644 spring-data-relational/src/main/java/org/springframework/data/relational/core/query/PgSqlImpl.java create mode 100644 spring-data-relational/src/main/java/org/springframework/data/relational/core/query/QueryExpression.java create mode 100644 spring-data-relational/src/main/java/org/springframework/data/relational/core/query/SimpleQueryRenderContext.java create mode 100644 spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/ArrayIndexExpression.java create mode 100644 spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/BaseFunction.java create mode 100644 spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/ConditionWrapper.java create mode 100644 spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/MultipleExpression.java create mode 100644 spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/NestedExpression.java create mode 100644 spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/OperatorExpression.java create mode 100644 spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/PostfixExpression.java create mode 100644 spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/ArrayIndexExpressionVisitor.java rename spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/{SimpleFunctionVisitor.java => FunctionVisitor.java} (78%) rename spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/{NestedConditionVisitor.java => NestedExpressionVisitor.java} (51%) rename spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/{ComparisonVisitor.java => OperatorExpressionVisitor.java} (64%) create mode 100644 spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/PostfixExpressionVisitor.java create mode 100644 spring-data-relational/src/test/java/org/springframework/data/relational/core/query/PgSqlUnitTests.java diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/MappingJdbcConverter.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/MappingJdbcConverter.java index 2c3feffdb6..557758b808 100644 --- a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/MappingJdbcConverter.java +++ b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/MappingJdbcConverter.java @@ -37,6 +37,7 @@ import org.springframework.data.mapping.context.MappingContext; import org.springframework.data.mapping.model.SimpleTypeHolder; import org.springframework.data.mapping.model.ValueExpressionEvaluator; +import org.springframework.data.relational.core.binding.BindMarkersFactory; import org.springframework.data.relational.core.conversion.MappingRelationalConverter; import org.springframework.data.relational.core.conversion.ObjectPath; import org.springframework.data.relational.core.conversion.RelationalConverter; @@ -283,6 +284,31 @@ public R readAndResolve(TypeInformation type, RowDocument source, Identif return readAggregate(context, source, entity.getTypeInformation()); } + public BindMarkersFactory getBindMarkersFactory() { + return BindMarkersFactory.named(":p", "", 32, MappingJdbcConverter::filterBindMarker); + } + + private static String filterBindMarker(CharSequence input) { + + StringBuilder builder = new StringBuilder(); + + for (int i = 0; i < input.length(); i++) { + + char ch = input.charAt(i); + + // ascii letter or digit + if (Character.isLetterOrDigit(ch) && ch < 127) { + builder.append(ch); + } + } + + if (builder.isEmpty()) { + return ""; + } + + return "_" + builder; + } + @Override protected RelationalPropertyValueProvider newValueProvider(RowDocumentAccessor documentAccessor, ValueExpressionEvaluator evaluator, ConversionContext context) { @@ -298,6 +324,7 @@ protected RelationalPropertyValueProvider newValueProvider(RowDocumentAccessor d return super.newValueProvider(documentAccessor, evaluator, context); } + /** * {@link RelationalPropertyValueProvider} using a resolving context to lookup relations. This is highly * context-sensitive. Note that the identifier is held here because of a chicken and egg problem, while diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/binding/AnonymousBindMarkers.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/binding/AnonymousBindMarkers.java new file mode 100644 index 0000000000..3d2a256c6c --- /dev/null +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/binding/AnonymousBindMarkers.java @@ -0,0 +1,59 @@ +/* + * Copyright 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.binding; + +import java.util.concurrent.atomic.AtomicIntegerFieldUpdater; + +import org.springframework.data.relational.core.sql.BindMarker; + +/** + * Anonymous, index-based bind markers that use a static placeholder. + *

+ * Instances are bound by the ordinal position ordered by the appearance of the placeholder. This implementation creates + * indexed bind markers using an anonymous placeholder that correlates with an index. + *

+ * Note: Anonymous bind markers are problematic because they have to appear in generated SQL in the same order they get + * generated. This might cause challenges in the future with complex generate statements. For example those containing + * subselects which limit the freedom of arranging bind markers. + * + * @author Mark Paluch + */ +class AnonymousBindMarkers implements BindMarkers { + + private static final AtomicIntegerFieldUpdater COUNTER_INCREMENTER = AtomicIntegerFieldUpdater + .newUpdater(AnonymousBindMarkers.class, "counter"); + + private final String placeholder; + + // access via COUNTER_INCREMENTER + @SuppressWarnings("unused") private volatile int counter; + + /** + * Create a new {@link AnonymousBindMarkers} instance for the given {@code placeholder}. + * + * @param placeholder parameter bind marker + */ + AnonymousBindMarkers(String placeholder) { + this.placeholder = placeholder; + } + + @Override + public BindMarker next() { + int index = COUNTER_INCREMENTER.getAndIncrement(this); + return BindMarker.indexed(this.placeholder, index); + } + +} diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/binding/BindMarkers.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/binding/BindMarkers.java new file mode 100644 index 0000000000..27045fc453 --- /dev/null +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/binding/BindMarkers.java @@ -0,0 +1,53 @@ +/* + * Copyright 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.binding; + +import org.springframework.data.relational.core.sql.BindMarker; + +/** + * Bind markers represent placeholders in SQL queries for substitution for an actual parameter. Using bind markers + * allows creating safe queries so query strings are not required to contain escaped values but rather the driver + * encodes the parameter in the appropriate representation. + *

+ * {@link BindMarkers} is stateful and can be only used for a single binding pass of one or more parameters. It + * maintains bind indexes or bind parameter names. + * + * @author Mark Paluch + * @since 5.3 + * @see BindMarker + * @see BindMarkersFactory + */ +@FunctionalInterface +public interface BindMarkers { + + /** + * Create a new {@link BindMarker}. + */ + BindMarker next(); + + /** + * Create a new {@link BindMarker} that accepts a {@code hint}. + *

+ * Implementations are allowed to consider/ignore/filter the name hint to create more expressive bind markers. + * + * @param hint an optional name hint that can be used as part of the bind marker + * @return a new {@link BindMarker} + */ + default BindMarker next(String hint) { + return next(); + } + +} diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/binding/BindMarkersFactory.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/binding/BindMarkersFactory.java new file mode 100644 index 0000000000..333b9dc5a9 --- /dev/null +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/binding/BindMarkersFactory.java @@ -0,0 +1,118 @@ +/* + * Copyright 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.binding; + +import java.util.function.Function; + +import org.springframework.data.relational.core.sql.BindMarker; +import org.springframework.util.Assert; + +/** + * This class creates new {@link BindMarkers} instances to bind parameter to a specific statement. + *

+ * Bind markers can be typically represented as placeholder and identifier. Placeholders are used within the query to + * execute so the underlying database system can substitute the placeholder with the actual value. Identifiers are used + * in R2DBC drivers to bind a value to a bind marker. Identifiers are typically a part of an entire bind marker when + * using indexed or named bind markers. + * + * @author Mark Paluch + * @see BindMarkers + */ +@FunctionalInterface +public interface BindMarkersFactory { + + /** + * Create a new {@link BindMarkers} instance. + */ + BindMarkers create(); + + /** + * Return whether the {@link BindMarkersFactory} uses identifiable placeholders: {@code false} if multiple + * placeholders cannot be distinguished by just the {@link BindMarker#getPlaceholder() placeholder} identifier. + */ + default boolean identifiablePlaceholders() { + return true; + } + + // Static factory methods + + /** + * Create index-based {@link BindMarkers} using indexes to bind parameters. Allows customization of the bind marker + * placeholder {@code prefix} to represent the bind marker as placeholder within the query. + * + * @param prefix bind parameter prefix that is included in {@link BindMarker#getPlaceholder()} but not the actual + * identifier + * @param beginWith the first index to use + * @return a {@link BindMarkersFactory} using {@code prefix} and {@code beginWith} + */ + static BindMarkersFactory indexed(String prefix, int beginWith) { + Assert.notNull(prefix, "Prefix must not be null"); + return () -> new IndexedBindMarkers(prefix, beginWith); + } + + /** + * Create anonymous, index-based bind marker using a static placeholder. Instances are bound by the ordinal position + * ordered by the appearance of the placeholder. This implementation creates indexed bind markers using an anonymous + * placeholder that correlates with an index. + * + * @param placeholder parameter placeholder + * @return a {@link BindMarkersFactory} using {@code placeholder} + */ + static BindMarkersFactory anonymous(String placeholder) { + Assert.hasText(placeholder, "Placeholder must not be empty"); + return new BindMarkersFactory() { + @Override + public BindMarkers create() { + return new AnonymousBindMarkers(placeholder); + } + + @Override + public boolean identifiablePlaceholders() { + return false; + } + }; + } + + /** + * Create named {@link BindMarkers} using identifiers to bind parameters. Named bind markers can support + * {@link BindMarkers#next(String) name hints}. If no {@link BindMarkers#next(String) hint} is given, named bind + * markers can use a counter or a random value source to generate unique bind markers. Allows customization of the + * bind marker placeholder {@code prefix} and {@code namePrefix} to represent the bind marker as placeholder within + * the query. + */ + static BindMarkersFactory named(String prefix, String namePrefix, int maxLength) { + return named(prefix, namePrefix, maxLength, Function.identity()); + } + + /** + * Create named {@link BindMarkers} using identifiers to bind parameters. Named bind markers support + * {@link BindMarkers#next(String) name hints}. If no {@link BindMarkers#next(String) hint} is given, named bind + * markers can use a counter or a random value source to generate unique bind markers. + * + * @param hintFilterFunction filter {@link Function} to consider database-specific limitations in bind marker/variable + * names such as ASCII chars only + * @return a {@link BindMarkersFactory} using {@code prefix} and {@code beginWith} + */ + static BindMarkersFactory named(String prefix, String namePrefix, int maxLength, + Function hintFilterFunction) { + + Assert.notNull(prefix, "Prefix must not be null"); + Assert.notNull(namePrefix, "Index prefix must not be null"); + Assert.notNull(hintFilterFunction, "Hint filter function must not be null"); + return () -> new NamedBindMarkers(prefix, namePrefix, maxLength, hintFilterFunction); + } + +} diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/binding/IndexedBindMarkers.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/binding/IndexedBindMarkers.java new file mode 100644 index 0000000000..a54b544882 --- /dev/null +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/binding/IndexedBindMarkers.java @@ -0,0 +1,60 @@ +/* + * Copyright 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.binding; + +import java.util.concurrent.atomic.AtomicIntegerFieldUpdater; + +import org.springframework.data.relational.core.sql.BindMarker; + +/** + * Index-based bind markers. This implementation creates indexed bind markers using a numeric index and an optional + * prefix for bind markers to be represented within the query string. + * + * @author Mark Paluch + * @author Jens Schauder + */ +class IndexedBindMarkers implements BindMarkers { + + private static final AtomicIntegerFieldUpdater COUNTER_INCREMENTER = AtomicIntegerFieldUpdater + .newUpdater(IndexedBindMarkers.class, "counter"); + + private final int offset; + + private final String prefix; + + // access via COUNTER_INCREMENTER + @SuppressWarnings("unused") private volatile int counter; + + /** + * Create a new indexed instance for the given {@code prefix} and {@code beginWith} value. + * + * @param prefix the bind parameter prefix + * @param beginIndex the first index to use + */ + IndexedBindMarkers(String prefix, int beginIndex) { + this.counter = 0; + this.prefix = prefix; + this.offset = beginIndex; + } + + @Override + public BindMarker next() { + int index = COUNTER_INCREMENTER.getAndIncrement(this); + return BindMarker.indexed(this.prefix + "" + (index + this.offset), index); + } + +} diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/binding/NamedBindMarkers.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/binding/NamedBindMarkers.java new file mode 100644 index 0000000000..c2f93e9d2d --- /dev/null +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/binding/NamedBindMarkers.java @@ -0,0 +1,76 @@ +/* + * Copyright 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.binding; + +import java.util.concurrent.atomic.AtomicIntegerFieldUpdater; +import java.util.function.Function; + +import org.springframework.data.relational.core.sql.BindMarker; +import org.springframework.util.Assert; + +/** + * Name-based bind markers. + * + * @author Mark Paluch + */ +class NamedBindMarkers implements BindMarkers { + + private static final AtomicIntegerFieldUpdater COUNTER_INCREMENTER = AtomicIntegerFieldUpdater + .newUpdater(NamedBindMarkers.class, "counter"); + + private final String prefix; + + private final String namePrefix; + + private final int nameLimit; + + private final Function hintFilterFunction; + + // access via COUNTER_INCREMENTER + @SuppressWarnings("unused") private volatile int counter; + + NamedBindMarkers(String prefix, String namePrefix, int nameLimit, Function hintFilterFunction) { + this.prefix = prefix; + this.namePrefix = namePrefix; + this.nameLimit = nameLimit; + this.hintFilterFunction = hintFilterFunction; + } + + @Override + public BindMarker next() { + String name = nextName(); + return BindMarker.named(this.prefix + name); + } + + @Override + public BindMarker next(String hint) { + Assert.notNull(hint, "Name hint must not be null"); + String name = nextName() + this.hintFilterFunction.apply(hint); + + if (name.length() > this.nameLimit) { + name = name.substring(0, this.nameLimit); + } + + return BindMarker.named(this.prefix + name); + } + + private String nextName() { + int index = COUNTER_INCREMENTER.getAndIncrement(this); + return this.namePrefix + index; + } + +} 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 a06b4e3b25..dbe7203360 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 @@ -70,7 +70,7 @@ public String createSequenceQuery(SqlIdentifier sequenceName) { } }; - protected PostgresDialect() {} + public PostgresDialect() {} private static final LimitClause LIMIT_CLAUSE = new LimitClause() { diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/query/Bindings.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/query/Bindings.java new file mode 100644 index 0000000000..88ed1305b3 --- /dev/null +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/query/Bindings.java @@ -0,0 +1,25 @@ +/* + * Copyright 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; + +/** + * @author Mark Paluch + */ +public interface Bindings { + + String bind(Object values); + +} 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..eb7f9101e0 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 @@ -15,6 +15,8 @@ */ package org.springframework.data.relational.core.query; +import static org.springframework.data.relational.core.query.CriteriaDefinition.Comparator.*; + import java.util.Arrays; import java.util.Collection; import java.util.Collections; @@ -63,26 +65,38 @@ public class Criteria implements CriteriaDefinition { private final Combinator combinator; private final List group; + private final @Nullable QueryExpression queryExpression; private final @Nullable SqlIdentifier column; private final @Nullable Comparator comparator; 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); + protected Criteria(QueryExpression expression) { + this(null, Combinator.INITIAL, Collections.emptyList(), expression, null, null, null, false); + } + + protected Criteria(SqlIdentifier column, Comparator comparator, @Nullable Object value) { + this(null, Combinator.INITIAL, Collections.emptyList(), null, column, comparator, value, false); + } + + protected Criteria(QueryExpression queryExpression, SqlIdentifier column, Comparator comparator, + @Nullable Object value) { + this(null, Combinator.INITIAL, Collections.emptyList(), queryExpression, column, comparator, value, false); } - private Criteria(@Nullable Criteria previous, Combinator combinator, List group, + protected 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, null, column, comparator, value, false); } - private Criteria(@Nullable Criteria previous, Combinator combinator, List group, - @Nullable SqlIdentifier column, @Nullable Comparator comparator, @Nullable Object value, boolean ignoreCase) { + protected Criteria(@Nullable Criteria previous, Combinator combinator, List group, + @Nullable QueryExpression queryExpression, @Nullable SqlIdentifier column, @Nullable Comparator comparator, + @Nullable Object value, boolean ignoreCase) { this.previous = previous; this.combinator = previous != null && previous.isEmpty() ? Combinator.INITIAL : combinator; this.group = group; + this.queryExpression = queryExpression; this.column = column; this.comparator = comparator; this.value = value; @@ -92,6 +106,7 @@ private Criteria(@Nullable Criteria previous, Combinator combinator, List group) { this.previous = previous; + this.queryExpression = null; this.combinator = previous != null && previous.isEmpty() ? Combinator.INITIAL : combinator; this.group = group; this.column = null; @@ -259,7 +274,7 @@ public Criteria or(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, queryExpression, column, comparator, value, ignoreCase); } return this; } @@ -344,6 +359,12 @@ public List getGroup() { return group; } + @Nullable + @Override + public QueryExpression getExpression() { + return queryExpression; + } + /** * @return the column/property name. */ @@ -658,11 +679,11 @@ public interface CriteriaStep { /** * Default {@link CriteriaStep} implementation. */ - static class DefaultCriteriaStep implements CriteriaStep { + protected static class DefaultCriteriaStep implements CriteriaStep { private final SqlIdentifier property; - DefaultCriteriaStep(SqlIdentifier property) { + protected DefaultCriteriaStep(SqlIdentifier property) { this.property = property; } @@ -734,7 +755,7 @@ public Criteria between(Object begin, Object end) { Assert.notNull(begin, "Begin value must not be null"); Assert.notNull(end, "End value must not be null"); - return createCriteria(Comparator.BETWEEN, Pair.of(begin, end)); + return createCriteria(BETWEEN, Pair.of(begin, end)); } @Override diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/query/CriteriaContext.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/query/CriteriaContext.java new file mode 100644 index 0000000000..c1407ff36c --- /dev/null +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/query/CriteriaContext.java @@ -0,0 +1,35 @@ +/* + * Copyright 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 org.springframework.data.relational.core.conversion.RelationalConverter; + +/** + * @author Mark Paluch + */ +public interface CriteriaContext { + + Object getSource(); + + RelationalConverter getConverter(); + + Bindings getBindings(); + + default String bind(Object value) { + return getBindings().bind(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..4072830bfd 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 @@ -85,6 +85,9 @@ static CriteriaDefinition from(List criteria) { List getGroup(); + @Nullable + QueryExpression getExpression(); + /** * @return the column/property name. */ diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/query/CriteriaSource.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/query/CriteriaSource.java new file mode 100644 index 0000000000..1ed30eaf15 --- /dev/null +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/query/CriteriaSource.java @@ -0,0 +1,33 @@ +/* + * Copyright 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 org.springframework.data.relational.core.sql.SqlIdentifier; + +/** + * @author Mark Paluch + */ +public interface CriteriaSource extends QueryExpression { + + static CriteriaSource ofDotPath(String name) { + return new CriteriaSources.DotPath(name); + } + + static CriteriaSource of(SqlIdentifier identifier) { + return new CriteriaSources.SqlIdentifierSource(identifier); + } + +} diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/query/CriteriaSources.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/query/CriteriaSources.java new file mode 100644 index 0000000000..f0ff65e809 --- /dev/null +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/query/CriteriaSources.java @@ -0,0 +1,50 @@ +/* + * Copyright 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 org.springframework.data.relational.core.sql.Expression; +import org.springframework.data.relational.core.sql.SqlIdentifier; + +/** + * @author Mark Paluch + */ +class CriteriaSources { + public static final record DotPath(String name) implements CriteriaSource { + + @Override + public QueryRenderContext contextualize(QueryRenderContext context) { + return context.withProperty(name); + } + + @Override + public Expression render(QueryRenderContext context) { + return context.getColumnName(name); + } + } + + public static final record SqlIdentifierSource(SqlIdentifier identifier) implements CriteriaSource { + + @Override + public QueryRenderContext contextualize(QueryRenderContext context) { + return context.withProperty(identifier); + } + + @Override + public Expression render(QueryRenderContext context) { + return context.getColumnName(identifier); + } + } +} diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/query/NestedQueryExpression.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/query/NestedQueryExpression.java new file mode 100644 index 0000000000..d828e92e6f --- /dev/null +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/query/NestedQueryExpression.java @@ -0,0 +1,47 @@ +/* + * Copyright 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 org.springframework.data.relational.core.sql.Expression; +import org.springframework.data.relational.core.sql.Expressions; + +/** + * @author Mark Paluch + */ +class NestedQueryExpression implements QueryExpression { + + private final QueryExpression expression; + + public NestedQueryExpression(QueryExpression expression) { + this.expression = expression; + } + + @Override + public QueryExpression nest() { + return this; + } + + @Override + public QueryRenderContext contextualize(QueryRenderContext context) { + return expression.contextualize(context); + } + + @Override + public Expression render(QueryRenderContext context) { + return Expressions.nest(expression.render(context)); + } + +} diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/query/PgSql.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/query/PgSql.java new file mode 100644 index 0000000000..44c46e7953 --- /dev/null +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/query/PgSql.java @@ -0,0 +1,353 @@ +/* + * Copyright 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 java.util.Arrays; +import java.util.function.Function; + +import org.springframework.data.domain.ScoringFunction; +import org.springframework.data.domain.Vector; + +/** + * @author Mark Paluch + */ +public final class PgSql { + + private PgSql() { + + } + + /** + * Creates a function expression for the given {@code functionName} using {@code arguments}. The function name is + * passed on directly to the SQL text so it must be a valid function name. Make sure to sanitize the function name + * when accepting it from user input. + * + * @param functionName name of the function to call, must not be {@literal null} or empty. + * @param arguments function arguments, can be any {@link QueryExpression} or simple values like {@code String}, + * {@code Integer}. + * @return the function expression for {@code functionName} with the given {@code arguments}. + */ + public static PostgresQueryExpression function(String functionName, Object... arguments) { + return new PgSqlImpl.FunctionExpression(functionName, Arrays.asList(arguments)); + } + + /** + * Entry point for creating a {@link PgCriteria.PostgresCriteriaStep} for the given column (or property specified as + * property path). + * + * @param column + * @return + */ + public static PgCriteria.PostgresCriteriaStep where(String column) { + return new PgSqlImpl.DefaultPostgresCriteriaStep(column); + } + + /** + * Entry point for creating a {@link PgCriteria.PostgresCriteriaStep} for the given column (or property specified as + * property path) that shall be wrapped by a wrapping function to process the column contents before its use in a + * downstream condition or expression. + * + * @param column + * @param wrappingFunction + * @return + */ + public static PgCriteria.PostgresCriteriaStep where(String column, + Function wrappingFunction) { + + PgSqlImpl.DefaultFunctions functions = new PgSqlImpl.DefaultFunctions(new CriteriaSources.DotPath(column)); + return where(wrappingFunction.apply(functions)); + } + + /** + * Entry point for creating a {@link PgCriteria.PostgresCriteriaStep} for the given {@link QueryExpression + * expression}. + * + * @param expression + * @return + */ + public static PgCriteria.PostgresCriteriaStep where(QueryExpression expression) { + return new PgSqlImpl.DefaultPostgresCriteriaStep(expression); + } + + public static ArrayFunctions arrays() { + + return new ArrayFunctions() { + @Override + public QueryExpression arrayOf(Object... values) { + return new PgSqlImpl.ArrayExpression(Arrays.asList(values)); + } + }; + } + + /** + * Postgres-specific {@link Criteria} implementation that provides access to Postgres-specific operators and + * functions. + */ + public static class PgCriteria extends Criteria { + + protected PgCriteria(QueryExpression source) { + super(source); + } + + public interface PostgresCriteriaStep extends CriteriaStep { + + /** + * {@code ?} operator for JSONB containment. + * + * @param value + * @return + */ + PgCriteria exists(Object value); + + /** + * {@code @>} operator for JSONB containment. + * + * @param value + * @return + */ + PgCriteria contains(Object value); + + /** + * {@code &&} operator for array containment. + * + * @param value + * @return + */ + PgCriteria overlaps(Object value); + + PostgresJsonCriteriaStep json(); + + } + + /** + * Fluent Postgres-specific JSON criteria API providing access to JSON operators and functions. + */ + public interface PostgresJsonCriteriaStep { + + PgCriteria contains(String field); + + PgCriteria contains(Object value); + + PgCriteria containsAll(Iterable values); + + PgCriteria containsAll(Object... values); + + PgCriteria containsAny(Iterable values); + + PgCriteria containsAny(Object... values); + + // @? + PgCriteria jsonPathMatches(String jsonPath); + + // @@ + PgCriteria jsonPath(String jsonPath); + + } + } + + /** + * Entrypoint for Postgres-specific functions. + */ + public interface Functions extends VectorSearchFunctions, JsonFunctions { + + /** + * Returns a {@link JsonFunctions} object providing access to JSON functions. + */ + default JsonFunctions json() { + return this; + } + + /** + * Returns a {@link VectorSearchFunctions} object providing access to pgvector functions. + */ + default VectorSearchFunctions vector() { + return this; + } + + } + + /** + * pgvector-specific functions for Postgres Vector Search. + */ + public interface VectorSearchFunctions { + + /** + * Calculates the distance to the given vector using the specified distance function. + * + * @param vector the vector to calculate the distance to. + * @param distanceFunction distance function to use, e.g. {@link Distances#l2()}, + * @return an expression representing the distance calculation. + */ + PostgresQueryExpression distanceTo(Vector vector, Function distanceFunction); + + /** + * Calculates the distance to the given vector using the specified distance function. + * + * @param vector the vector to calculate the distance to. + * @param scoringFunction scoring function to use, e.g. {@link Distances#l2()}, + * @return an expression representing the distance calculation. + */ + default PostgresQueryExpression distanceTo(Vector vector, ScoringFunction scoringFunction) { + return distanceTo(vector, distances -> scoringFunction); + } + + interface Distances { + + default ScoringFunction l2() { + return ScoringFunction.euclidean(); + } + + default ScoringFunction innerProduct() { + return ScoringFunction.dotProduct(); + } + + default ScoringFunction cosine() { + return ScoringFunction.cosine(); + } + + default ScoringFunction l1() { + return of("<+>"); + } + + default ScoringFunction hamming() { + return of("<~>"); + } + + default ScoringFunction jaccard() { + return of("<%>"); + } + + default ScoringFunction of(String operator) { + return () -> operator; + } + } + } + + /** + * Postgres-specific Array functions for querying array data. + */ + public interface ArrayFunctions { + + /** + * Constructs an {@code ARRAY[…]} expression from the given values. + * + * @param values + * @return an expression that represents a Postgres array of the given values. + */ + QueryExpression arrayOf(Object... values); + + } + + /** + * Postgres-specific JSON functions for querying JSON data. + */ + public interface JsonFunctions { + + /** + * Creates an index expression to extract a value from a JSON array at the given {@code index} using the arrow + * {@code ->} operator. + * + * @param index index of the element to extract from the JSON array. + * @return an expression that extracts the value at the specified {@code index}. + */ + PostgresQueryExpression index(int index); + + /** + * Creates an index expression to extract a value from a JSON object at the given {@code field} using the arrow + * {@code ->} operator. + * + * @param field name of the field to extract from the JSON object. + * @return an expression that extracts the value using the specified {@code field}. + */ + PostgresQueryExpression field(String field); + } + + /** + * Postgres-specific query expression that allows to append Postgres-specific type casts and array indexing. + */ + public interface PostgresQueryExpression extends QueryExpression { + + /** + * Cast the expression to the given type by appending {@code ::type} to the expression. + * + * @param type + * @return + */ + default PostgresQueryExpression as(String type) { + return new PgSqlImpl.AppendingPostgresExpression(this, "::" + type); + } + + /** + * Cast the expression to boolean type by appending {@code ::boolean} to the expression. + * + * @return the casted expression. + */ + default PostgresQueryExpression asBoolean() { + return as("boolean"); + } + + /** + * Cast the expression to varchar type by appending {@code ::varchar} to the expression. + * + * @return the casted expression. + */ + default PostgresQueryExpression asString() { + return as("varchar"); + } + + /** + * Cast the expression to varchar type by appending {@code ::json} the expression. + * + * @return the casted expression. + */ + default PostgresQueryExpression asJson() { + return as("json"); + } + + /** + * Cast the expression to varchar type by appending {@code ::jsonb} the expression. + * + * @return the casted expression. + */ + default PostgresQueryExpression asJsonb() { + return as("jsonb"); + } + + /** + * Creates an index expression for the given index using {@code […]} brackets. Can be used with arrays or JSON + * arrays. + * + * @param index + * @return + */ + default PostgresQueryExpression index(int index) { + return new PgSqlImpl.ArrayIndexPostgresExpression(this, index); + } + + /** + * Creates an index expression for the given index using {@code […]} brackets. Can be used with JSON arrays for + * by-field access. + * + * @param key + * @return + */ + default PostgresQueryExpression element(String key) { + return new PgSqlImpl.ArrayIndexPostgresExpression(this, key); + } + + } + +} diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/query/PgSqlImpl.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/query/PgSqlImpl.java new file mode 100644 index 0000000000..4d58e1e065 --- /dev/null +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/query/PgSqlImpl.java @@ -0,0 +1,347 @@ +/* + * Copyright 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 java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.function.Function; + +import org.springframework.data.domain.ScoringFunction; +import org.springframework.data.domain.Vector; +import org.springframework.data.relational.core.sql.*; +import org.springframework.lang.Nullable; + +/** + * Implementation of the Postgres dialect for criteria and query expressions. + * + * @author Mark Paluch + * @since 4.0 + */ +class PgSqlImpl { + + static class DefaultPostgresCriteriaStep extends Criteria.DefaultCriteriaStep + implements PgSql.PgCriteria.PostgresCriteriaStep { + + private final QueryExpression source; + + protected DefaultPostgresCriteriaStep(String propertyName) { + this(CriteriaSource.ofDotPath(propertyName)); + } + + protected DefaultPostgresCriteriaStep(QueryExpression source) { + super(SqlIdentifier.unquoted("foo")); + this.source = source; + } + + protected DefaultPostgresCriteriaStep(CriteriaSources.DotPath identifier) { + super(SqlIdentifier.unquoted(identifier.name())); + this.source = identifier; + } + + protected DefaultPostgresCriteriaStep(CriteriaSources.SqlIdentifierSource identifier) { + super(identifier.identifier()); + this.source = identifier; + } + + protected DefaultPostgresCriteriaStep(SqlIdentifier sqlIdentifier) { + super(sqlIdentifier); + this.source = CriteriaSource.of(sqlIdentifier); + } + + protected Criteria createCriteria(CriteriaDefinition.Comparator comparator, @Nullable Object value) { + + return new PgSql.PgCriteria(new PostgresComparison(source, + (comparator == CriteriaDefinition.Comparator.IS_TRUE || comparator == CriteriaDefinition.Comparator.IS_FALSE) + ? "=" + : comparator.getComparator(), + new ValueExpression(value))); + } + + protected PgSql.PgCriteria createCriteria(String operator, Object value) { + return createCriteria(new PostgresComparison(source, operator, + value instanceof QueryExpression e ? e : new ValueExpression(value))); + } + + protected PgSql.PgCriteria createCriteria(QueryExpression queryExpression) { + return new PgSql.PgCriteria(queryExpression); + } + + @Override + public PgSql.PgCriteria exists(Object value) { + return createCriteria("?", value); + } + + @Override + public PgSql.PgCriteria contains(Object value) { + return createCriteria("@>", value); + } + + @Override + public PgSql.PgCriteria overlaps(Object value) { + return createCriteria("?", value); + } + + @Override + public PgSql.PgCriteria.PostgresJsonCriteriaStep json() { + return new PgSql.PgCriteria.PostgresJsonCriteriaStep() { + + @Override + public PgSql.PgCriteria contains(String field) { + return createCriteria("?", new ValueExpression(field)); + } + + @Override + public PgSql.PgCriteria contains(Object value) { + return createCriteria("?", value); + } + + @Override + public PgSql.PgCriteria containsAll(Iterable values) { + return createCriteria("?&", new ArrayExpression(values)); + } + + @Override + public PgSql.PgCriteria containsAll(Object... values) { + return createCriteria("?&", new ArrayExpression(Arrays.asList(values))); + } + + @Override + public PgSql.PgCriteria containsAny(Iterable values) { + return createCriteria("?|", new ArrayExpression(values)); + } + + @Override + public PgSql.PgCriteria containsAny(Object... values) { + return createCriteria("?|", new ArrayExpression(Arrays.asList(values))); + } + + @Override + public PgSql.PgCriteria jsonPathMatches(String jsonPath) { + return createCriteria("@?", new ValueExpression(jsonPath)); + } + + @Override + public PgSql.PgCriteria jsonPath(String jsonPath) { + return createCriteria("@@", new ValueExpression(jsonPath)); + } + }; + } + } + + private record PostgresComparison(QueryExpression lhs, String operator, + QueryExpression rhs) implements QueryExpression { + + @Override + public QueryRenderContext contextualize(QueryRenderContext context) { + return this.rhs.contextualize(this.lhs.contextualize(context)); + } + + @Override + public Expression render(QueryRenderContext context) { + + Expression lhs = this.lhs.render(context); + Expression rhs = this.rhs.render(context); + + return Comparison.create(lhs, operator, rhs); + } + + } + + private record ValueExpression(Object value) implements QueryExpression { + + @Override + public Expression render(QueryRenderContext context) { + return context.bind(value); + } + } + + static class ValuesExpression implements QueryExpression { + + private final Iterable values; + + public ValuesExpression(Iterable values) { + this.values = values; + } + + @Override + public Expression render(QueryRenderContext context) { + + List bindMarkers = new ArrayList<>(); + for (Object value : this.values) { + bindMarkers.add(context.bind(value)); + } + + return TupleExpression.create(bindMarkers); + } + } + + record FunctionExpression(String function, Iterable values) implements PgSql.PostgresQueryExpression { + + @Override + public Expression render(QueryRenderContext context) { + return SimpleFunction.create(function, createArgumentExpressions(values(), context)); + } + + private static List createArgumentExpressions(Iterable values, QueryRenderContext context) { + + List arguments = new ArrayList<>(); + for (Object value : values) { + if (value == null) { + arguments.add(Expressions.just("NULL")); + } else if (value instanceof QueryExpression qe) { + arguments.add(qe.render(context)); + } else { + arguments.add(context.bind(value)); + } + } + return arguments; + } + } + + record ArrayExpression(Iterable values) implements QueryExpression { + + @Override + public Expression render(QueryRenderContext context) { + return BaseFunction.create("array", "[", "]", FunctionExpression.createArgumentExpressions(values(), context)); + } + } + + record DefaultFunctions(CriteriaSource source) implements PgSql.Functions { + + @Override + public PgSql.PostgresQueryExpression index(int index) { + return new JsonIndexFunction(source, JsonIndexFunction.FIELD_OR_INDEX, index); + } + + @Override + public PgSql.PostgresQueryExpression field(String field) { + return new JsonIndexFunction(source, JsonIndexFunction.FIELD_OR_INDEX, field); + } + + @Override + public PgSql.PostgresQueryExpression distanceTo(Vector vector, + Function distanceFunction) { + return new DistanceFunction(source, distanceFunction.apply(new Distances() {}), vector); + } + } + + record DistanceFunction(CriteriaSource source, ScoringFunction scoringFunction, + Vector vector) implements PgSql.PostgresQueryExpression { + + @Override + public QueryRenderContext contextualize(QueryRenderContext context) { + return source.contextualize(context); + } + + @Override + public Expression render(QueryRenderContext context) { + + String operator = getOperator(); + + return OperatorExpression.create(source.render(context), operator, context.bind(vector)); + } + + private String getOperator() { + + if (scoringFunction == ScoringFunction.cosine()) { + return "<=>"; + } else if (scoringFunction == ScoringFunction.euclidean()) { + return "<->"; + } else if (scoringFunction == ScoringFunction.dotProduct()) { + return "<#>"; + } + + return scoringFunction.getName(); + } + } + + static class JsonIndexFunction implements PgSql.PostgresQueryExpression { + + public static final String FIELD_OR_INDEX = "-"; + public static final String PATH = "#"; + + private final CriteriaSource source; + private final String baseOperator; + private final boolean asString; + private final Object keyOrIndex; + + public JsonIndexFunction(CriteriaSource source, String baseOperator, Object keyOrIndex) { + this(source, baseOperator, false, keyOrIndex); + } + + public JsonIndexFunction(CriteriaSource source, String baseOperator, boolean asString, Object keyOrIndex) { + this.source = source; + this.baseOperator = baseOperator; + this.asString = asString; + this.keyOrIndex = keyOrIndex; + } + + @Override + public PgSql.PostgresQueryExpression asString() { + return new JsonIndexFunction(this.source, this.baseOperator, true, this.keyOrIndex); + } + + @Override + public PgSql.PostgresQueryExpression asJson() { + return this; + } + + @Override + public PgSql.PostgresQueryExpression asJsonb() { + return this; + } + + @Override + public PgSql.PostgresQueryExpression as(String type) { + return new AppendingPostgresExpression(this.nest(), "::" + type); + } + + @Override + public QueryRenderContext contextualize(QueryRenderContext context) { + return source.contextualize(context); + } + + @Override + public Expression render(QueryRenderContext context) { + return OperatorExpression.create(source.render(context), baseOperator + (asString ? ">>" : ">"), + context.bind(keyOrIndex)); + } + } + + record AppendingPostgresExpression(QueryExpression source, String appendix) implements PgSql.PostgresQueryExpression { + + @Override + public QueryRenderContext contextualize(QueryRenderContext context) { + return source.contextualize(context); + } + + @Override + public Expression render(QueryRenderContext context) { + return new PostfixExpression(source.render(context), Expressions.just(appendix)); + } + } + + record ArrayIndexPostgresExpression(QueryExpression source, + Object keyOrIndex) implements PgSql.PostgresQueryExpression { + + @Override + public Expression render(QueryRenderContext context) { + return new PostfixExpression(source.render(context), new ArrayIndexExpression(context.bind(keyOrIndex))); + } + } + +} diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/query/QueryExpression.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/query/QueryExpression.java new file mode 100644 index 0000000000..08c5146ead --- /dev/null +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/query/QueryExpression.java @@ -0,0 +1,89 @@ +/* + * Copyright 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 org.springframework.data.relational.core.sql.BindMarker; +import org.springframework.data.relational.core.sql.Expression; +import org.springframework.data.relational.core.sql.SqlIdentifier; + +/** + * An expression that can be used in a query, such as a column, condition or a function. + *

+ * Expressions represent parts of a query that can be used for selection, ordering, and filtering. It can capture input + * values and render parameter placeholders for binding values. Reusing an expression reuses the same parameters (values + * and placeholders). + * + * @author Mark Paluch + * @since 4.0s + */ +public interface QueryExpression { + + /** + * Nests this expression by wrapping it in parentheses {@code (…)}. This is useful for grouping expressions in a query + * or to clarify an operator scope to avoid ambiguity in complex expressions. + * + * @return this expression as nested expression. + */ + default QueryExpression nest() { + return new NestedQueryExpression(this); + } + + // TODO: Used to determine the inner-most context. + default QueryRenderContext contextualize(QueryRenderContext context) { + return context; + } + + /** + * Renders this expression into a SQL {@link Expression} that can be used in a query. + * + * @implNote an expression such as a comparison requires a contextualized {@link QueryRenderContext} that considers + * the left-hand side and the right-hand side of the expression. It is necessary to determine the target + * type when comparing a column to a given value. But what if we compare two columns? Something like: + * + *

+	 *           CREATE TABLE person (id int, name varchar(100), country varchar(2), no_visa_required varchar[2]);
+	 *           SELECT * FROM person WHERE no_visa_required && (overlap, any elements in common) ARRAY('DE, country, '??');
+	 *           SELECT * FROM person WHERE (no_visa_required || country) && (overlap) ARRAY('DE, '??');
+	 *           
+ * + * @param context + * @return + */ + Expression render(QueryRenderContext context); + + // TODO better design to support nested expressions + interface QueryRenderContext { + + QueryRenderContext withProperty(String dotPath); + + QueryRenderContext withProperty(SqlIdentifier identifier); + + Expression getColumnName(SqlIdentifier identifier); + + Expression getColumnName(String dotPath); + + // only available after contextualization + Expression getColumnName(); + + BindMarker bind(Object value); + + BindMarker bind(String name, Object value); + + Object writeValue(Object value); + + } + +} diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/query/SimpleQueryRenderContext.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/query/SimpleQueryRenderContext.java new file mode 100644 index 0000000000..dd7391e8cb --- /dev/null +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/query/SimpleQueryRenderContext.java @@ -0,0 +1,282 @@ +/* + * Copyright 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 java.util.regex.Pattern; + +import org.springframework.data.mapping.MappingException; +import org.springframework.data.mapping.PersistentPropertyPath; +import org.springframework.data.mapping.PropertyPath; +import org.springframework.data.mapping.PropertyReferenceException; +import org.springframework.data.mapping.context.MappingContext; +import org.springframework.data.relational.core.binding.BindMarkers; +import org.springframework.data.relational.core.binding.BindMarkersFactory; +import org.springframework.data.relational.core.conversion.RelationalConverter; +import org.springframework.data.relational.core.mapping.RelationalPersistentEntity; +import org.springframework.data.relational.core.mapping.RelationalPersistentProperty; +import org.springframework.data.relational.core.sql.BindMarker; +import org.springframework.data.relational.core.sql.Expression; +import org.springframework.data.relational.core.sql.SqlIdentifier; +import org.springframework.data.relational.core.sql.Table; +import org.springframework.data.util.TypeInformation; +import org.springframework.lang.Nullable; +import org.springframework.util.Assert; +import org.springframework.util.ClassUtils; + +/** + * @author Mark Paluch + */ +public class SimpleQueryRenderContext implements QueryExpression.QueryRenderContext { + + private final Table table; + private final BindMarkers bindMarkers; + private final RelationalConverter converter; + private final RelationalPersistentEntity entity; + private final @Nullable Field field; + + public SimpleQueryRenderContext(Table table, BindMarkersFactory bindMarkersFactory, RelationalConverter converter, + RelationalPersistentEntity entity) { + + this.table = table; + this.bindMarkers = bindMarkersFactory.create(); + this.converter = converter; + this.entity = entity; + this.field = null; + } + + public SimpleQueryRenderContext(Table table, BindMarkers bindMarkers, RelationalConverter converter, + RelationalPersistentEntity entity, @Nullable Field field) { + this.table = table; + this.bindMarkers = bindMarkers; + this.converter = converter; + this.entity = entity; + this.field = field; + } + + Field createPropertyField(SqlIdentifier key) { + return entity == null ? new Field(key) : new MetadataBackedField(key, entity, converter.getMappingContext()); + } + + Field createPropertyField(SqlIdentifier key, + MappingContext, ? extends RelationalPersistentProperty> mappingContext) { + return entity == null ? new Field(key) : new MetadataBackedField(key, entity, mappingContext); + } + + @Override + public QueryExpression.QueryRenderContext withProperty(String dotPath) { + // todo: check RelationalMappingContext.isForceQuote() and use SqlIdentifier.quoted() if necessary + return new SimpleQueryRenderContext(table, bindMarkers, converter, entity, + createPropertyField(SqlIdentifier.unquoted(dotPath))); + } + + @Override + public QueryExpression.QueryRenderContext withProperty(SqlIdentifier identifier) { + return new SimpleQueryRenderContext(table, bindMarkers, converter, entity, createPropertyField(identifier)); + } + + @Override + public Expression getColumnName(SqlIdentifier identifier) { + + if (field == null) { + return withProperty(identifier).getColumnName(); + } + return table.column(createPropertyField(identifier).getMappedColumnName()); + } + + @Override + public Expression getColumnName(String dotPath) { + + if (field == null) { + return withProperty(dotPath).getColumnName(); + } + + return table.column(createPropertyField(SqlIdentifier.unquoted(dotPath)).getMappedColumnName()); + } + + @Override + public BindMarker bind(Object value) { + + // TODO + return bindMarkers.next(); + } + + @Override + public BindMarker bind(String name, Object value) { + + // TODO + return bindMarkers.next(name); + } + + @Override + public Object writeValue(Object value) { + return converter.writeValue(value, field == null ? TypeInformation.OBJECT : field.getTypeHint()); + } + + @Override + public Expression getColumnName() { + + if (field == null) { + throw new IllegalStateException("RenderContext not associated with a field. Call withProperty(…) first."); + } + + return table.column(field.getMappedColumnName()); + } + + /** + * Value object to represent a field and its meta-information. + */ + protected static class Field { + + protected final SqlIdentifier name; + + /** + * Creates a new {@link Field} without meta-information but the given name. + * + * @param name must not be {@literal null} or empty. + */ + public Field(SqlIdentifier name) { + + Assert.notNull(name, "Name must not be null"); + this.name = name; + } + + /** + * Returns the key to be used in the mapped document eventually. + * + * @return + */ + public SqlIdentifier getMappedColumnName() { + return this.name; + } + + public TypeInformation getTypeHint() { + return TypeInformation.OBJECT; + } + } + + /** + * Extension of {@link Field} to be backed with mapping metadata. + */ + protected static class MetadataBackedField extends Field { + + private final RelationalPersistentEntity entity; + private final MappingContext, ? extends RelationalPersistentProperty> mappingContext; + private final @Nullable RelationalPersistentProperty property; + private final @Nullable PersistentPropertyPath path; + + /** + * Creates a new {@link MetadataBackedField} with the given name, {@link RelationalPersistentEntity} and + * {@link MappingContext}. + * + * @param name must not be {@literal null} or empty. + * @param entity must not be {@literal null}. + * @param context must not be {@literal null}. + */ + protected MetadataBackedField(SqlIdentifier name, RelationalPersistentEntity entity, + MappingContext, ? extends RelationalPersistentProperty> context) { + this(name, entity, context, null); + } + + /** + * Creates a new {@link MetadataBackedField} with the given name, {@link RelationalPersistentEntity} and + * {@link MappingContext} with the given {@link RelationalPersistentProperty}. + * + * @param name must not be {@literal null} or empty. + * @param entity must not be {@literal null}. + * @param context must not be {@literal null}. + * @param property may be {@literal null}. + */ + protected MetadataBackedField(SqlIdentifier name, RelationalPersistentEntity entity, + MappingContext, ? extends RelationalPersistentProperty> context, + @Nullable RelationalPersistentProperty property) { + + super(name); + + Assert.notNull(entity, "RelationalPersistentEntity must not be null"); + + this.entity = entity; + this.mappingContext = context; + + this.path = getPath(name.getReference()); + this.property = this.path == null ? property : this.path.getLeafProperty(); + } + + @Override + public SqlIdentifier getMappedColumnName() { + return this.path == null || this.path.getLeafProperty() == null ? super.getMappedColumnName() + : this.path.getLeafProperty().getColumnName(); + } + + /** + * Returns the {@link PersistentPropertyPath} for the given {@code pathExpression}. + * + * @param pathExpression the path expression to use. + * @return + */ + @Nullable + private PersistentPropertyPath getPath(String pathExpression) { + + try { + + PropertyPath path = forName(pathExpression); + + if (isPathToJavaLangClassProperty(path)) { + return null; + } + + return this.mappingContext.getPersistentPropertyPath(path); + } catch (MappingException | PropertyReferenceException e) { + return null; + } + } + + private PropertyPath forName(String path) { + + if (entity.getPersistentProperty(path) != null) { + return PropertyPath.from(Pattern.quote(path), entity.getTypeInformation()); + } + + return PropertyPath.from(path, entity.getTypeInformation()); + } + + private boolean isPathToJavaLangClassProperty(PropertyPath path) { + return path.getType().equals(Class.class) && path.getLeafProperty().getOwningType().getType().equals(Class.class); + } + + @Override + public TypeInformation getTypeHint() { + + if (this.property == null) { + return super.getTypeHint(); + } + + if (this.property.getType().isPrimitive()) { + return TypeInformation.of(ClassUtils.resolvePrimitiveIfNecessary(this.property.getType())); + } + + if (this.property.getType().isArray()) { + return this.property.getTypeInformation(); + } + + if (this.property.getType().isInterface() + || (java.lang.reflect.Modifier.isAbstract(this.property.getType().getModifiers()))) { + return TypeInformation.OBJECT; + } + + return this.property.getTypeInformation(); + } + } +} diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/AndCondition.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/AndCondition.java index 7d43577bd4..247c54dce2 100644 --- a/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/AndCondition.java +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/AndCondition.java @@ -24,7 +24,7 @@ */ public class AndCondition extends MultipleCondition { - AndCondition(Condition... conditions) { + AndCondition(Expression... conditions) { super(" AND ", conditions); } } diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/ArrayIndexExpression.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/ArrayIndexExpression.java new file mode 100644 index 0000000000..0626f243da --- /dev/null +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/ArrayIndexExpression.java @@ -0,0 +1,37 @@ +/* + * Copyright 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.sql; + +/** + * @author Mark Paluch + */ +public class ArrayIndexExpression extends AbstractSegment implements Expression { + + private final Expression index; + + public ArrayIndexExpression(Expression index) { + this.index = index; + } + + public Expression getIndex() { + return index; + } + + @Override + public String toString() { + return "[" + index + "]"; + } +} diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/BaseFunction.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/BaseFunction.java new file mode 100644 index 0000000000..68114ec4a7 --- /dev/null +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/BaseFunction.java @@ -0,0 +1,137 @@ +/* + * Copyright 2019-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.sql; + +import java.util.Collections; +import java.util.List; + +import org.springframework.util.Assert; +import org.springframework.util.StringUtils; + +/** + * Simple function accepting one or more {@link Expression}s and definition how parentheses should be rendered. + * + * @author Mark Paluch + * @since 4.0 + */ +public class BaseFunction extends AbstractSegment implements Expression { + + private final String functionName; + private final String beforeArgs; + private final String afterArgs; + + private final List expressions; + + protected BaseFunction(String functionName, String beforeArgs, String afterArgs, + List expressions) { + + super(expressions.toArray(new Expression[0])); + + this.functionName = functionName; + this.beforeArgs = beforeArgs; + this.afterArgs = afterArgs; + this.expressions = expressions; + } + + /** + * Creates a new {@link BaseFunction} given {@code functionName} and {@link List} of {@link Expression}s. + * + * @param functionName must not be {@literal null}. + * @param expressions zero or many {@link Expression}s, must not be {@literal null}. + * @return + */ + public static BaseFunction create(String functionName, String openParenthesis, String closeParenthesis, + List expressions) { + + Assert.hasText(functionName, "Function name must not be null or empty"); + Assert.notNull(expressions, "Expressions name must not be null"); + + return new BaseFunction(functionName, openParenthesis, closeParenthesis, expressions); + } + + /** + * Expose this function result under a column {@code alias}. + * + * @param alias column alias name, must not {@literal null} or empty. + * @return the aliased {@link BaseFunction}. + */ + public BaseFunction as(String alias) { + + Assert.hasText(alias, "Alias must not be null or empty"); + + return new AliasedFunction(this, SqlIdentifier.unquoted(alias)); + } + + /** + * Expose this function result under a column {@code alias}. + * + * @param alias column alias name, must not {@literal null}. + * @return the aliased {@link BaseFunction}. + * @since 2.0 + */ + public BaseFunction as(SqlIdentifier alias) { + + Assert.notNull(alias, "Alias must not be null"); + + return new AliasedFunction(this, alias); + } + + /** + * @return the function name. + */ + public String getFunctionName() { + return functionName; + } + + public String getBeforeArgs() { + return beforeArgs; + } + + public String getAfterArgs() { + return afterArgs; + } + + /** + * @return the function arguments. + * @since 2.0 + */ + public List getExpressions() { + return Collections.unmodifiableList(expressions); + } + + @Override + public String toString() { + return functionName + getBeforeArgs() + StringUtils.collectionToDelimitedString(expressions, ", ") + getAfterArgs(); + } + + /** + * {@link Aliased} {@link BaseFunction} implementation. + */ + static class AliasedFunction extends BaseFunction implements Aliased { + + private final SqlIdentifier alias; + + AliasedFunction(BaseFunction function, SqlIdentifier alias) { + super(function.getFunctionName(), function.getBeforeArgs(), function.getAfterArgs(), function.expressions); + this.alias = alias; + } + + @Override + public SqlIdentifier getAlias() { + return alias; + } + } +} diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/BindMarker.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/BindMarker.java index 185453e4b3..12e81143bd 100644 --- a/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/BindMarker.java +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/BindMarker.java @@ -15,6 +15,8 @@ */ package org.springframework.data.relational.core.sql; +import org.springframework.util.ObjectUtils; + /** * Bind marker/parameter placeholder used to construct prepared statements with parameter substitution. * @@ -23,6 +25,18 @@ */ public class BindMarker extends AbstractSegment implements Expression { + public static BindMarker named(String name) { + return new NamedBindMarker(name); + } + + public static BindMarker indexed(int index) { + return new IndexedBindMarker("" + index, index); + } + + public static BindMarker indexed(String name, int index) { + return new IndexedBindMarker(name, index); + } + @Override public String toString() { return "?"; @@ -41,9 +55,65 @@ public SqlIdentifier getName() { return SqlIdentifier.unquoted(name); } + @Override + public boolean equals(Object o) { + if (!(o instanceof NamedBindMarker that)) { + return false; + } + if (!super.equals(o)) { + return false; + } + return ObjectUtils.nullSafeEquals(name, that.name); + } + + @Override + public int hashCode() { + return super.hashCode() * 31 + ObjectUtils.nullSafeHash(name); + } + @Override public String toString() { return "?[" + name + "]"; } } + + static class IndexedBindMarker extends BindMarker implements Named { + + private final int index; + private final String name; + + IndexedBindMarker(String name, int index) { + this.name = name; + this.index = index; + } + + @Override + public SqlIdentifier getName() { + return SqlIdentifier.unquoted(name); + } + + @Override + public boolean equals(Object o) { + if (!(o instanceof IndexedBindMarker that)) { + return false; + } + if (!super.equals(o)) { + return false; + } + if (index != that.index) { + return false; + } + return ObjectUtils.nullSafeEquals(name, that.name); + } + + @Override + public int hashCode() { + return super.hashCode() * 31 + ObjectUtils.nullSafeHash(index, name); + } + + @Override + public String toString() { + return "?" + index; + } + } } diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/Comparison.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/Comparison.java index d7bec78feb..5e04c7d889 100644 --- a/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/Comparison.java +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/Comparison.java @@ -27,7 +27,7 @@ * @author Jens Schauder * @since 1.1 */ -public class Comparison extends AbstractSegment implements Condition { +public class Comparison extends OperatorExpression implements Condition { private final Expression left; private final String comparator; @@ -35,7 +35,7 @@ public class Comparison extends AbstractSegment implements Condition { private Comparison(Expression left, String comparator, Expression right) { - super(left, right); + super(left, comparator, right); this.left = left; this.comparator = comparator; @@ -94,29 +94,4 @@ public Condition not() { return new Not(this); } - /** - * @return the left {@link Expression}. - */ - public Expression getLeft() { - return left; - } - - /** - * @return the comparator. - */ - public String getComparator() { - return comparator; - } - - /** - * @return the right {@link Expression}. - */ - public Expression getRight() { - return right; - } - - @Override - public String toString() { - return left + " " + comparator + " " + right; - } } diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/Condition.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/Condition.java index 59ad5f2711..445e5e10ff 100644 --- a/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/Condition.java +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/Condition.java @@ -28,20 +28,20 @@ public interface Condition extends Segment, Expression { /** * Combine another {@link Condition} using {@code AND}. * - * @param other the other {@link Condition}. + * @param other the other {@link Expression condition}. * @return the combined {@link Condition}. */ - default Condition and(Condition other) { + default Condition and(Expression other) { return new AndCondition(this, other); } /** * Combine another {@link Condition} using {@code OR}. * - * @param other the other {@link Condition}. + * @param other the other {@link Expression condition}. * @return the combined {@link Condition}. */ - default Condition or(Condition other) { + default Condition or(Expression other) { return new OrCondition(this, other); } diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/ConditionWrapper.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/ConditionWrapper.java new file mode 100644 index 0000000000..ab073c345e --- /dev/null +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/ConditionWrapper.java @@ -0,0 +1,39 @@ +/* + * Copyright 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.sql; + +/** + * @author Mark Paluch + */ +class ConditionWrapper extends AbstractSegment implements Condition { + + private final Expression expression; + + public ConditionWrapper(Expression expression) { + super(expression); + this.expression = expression; + } + + public static Condition of(Expression other) { + + if (other instanceof Condition c) { + return c; + } + + return new ConditionWrapper(other); + } + +} diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/DefaultSelect.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/DefaultSelect.java index 9aa5975dd1..f27593eab9 100644 --- a/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/DefaultSelect.java +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/DefaultSelect.java @@ -44,7 +44,7 @@ class DefaultSelect implements Select { private final @Nullable LockMode lockMode; DefaultSelect(boolean distinct, List selectList, List from, long limit, long offset, - List joins, @Nullable Condition where, List orderBy, @Nullable LockMode lockMode) { + List joins, @Nullable Expression where, List orderBy, @Nullable LockMode lockMode) { this.distinct = distinct; this.selectList = new SelectList(new ArrayList<>(selectList)); diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/DefaultSelectBuilder.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/DefaultSelectBuilder.java index 0bc6fe2d36..941cc1dc49 100644 --- a/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/DefaultSelectBuilder.java +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/DefaultSelectBuilder.java @@ -42,7 +42,7 @@ class DefaultSelectBuilder implements SelectBuilder, SelectAndFrom, SelectFromAn private long limit = -1; private long offset = -1; private final List joins = new ArrayList<>(); - private @Nullable Condition where; + private @Nullable Expression where; private final List orderBy = new ArrayList<>(); private @Nullable LockMode lockMode; @@ -153,16 +153,23 @@ public SelectWhereAndOr where(Condition condition) { } @Override - public SelectWhereAndOr and(Condition condition) { + public SelectWhereAndOr where(Expression expression) { - where = where.and(condition); + where = expression; return this; } @Override - public SelectWhereAndOr or(Condition condition) { + public SelectWhereAndOr and(Expression condition) { - where = where.or(condition); + where = where instanceof Condition c ? c.and(condition) : new AndCondition(where, condition); + return this; + } + + @Override + public SelectWhereAndOr or(Expression condition) { + + where = where instanceof Condition c ? c.or(condition) : new OrCondition(where, condition); return this; } @@ -313,6 +320,12 @@ public SelectWhereAndOr where(Condition condition) { return selectBuilder.where(condition); } + @Override + public SelectWhereAndOr where(Expression expression) { + selectBuilder.join(finishJoin()); + return selectBuilder.where(expression); + } + @Override public SelectOn join(String table) { selectBuilder.join(finishJoin()); diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/Disjunct.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/Disjunct.java index 8283cd1ed2..e511a69901 100644 --- a/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/Disjunct.java +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/Disjunct.java @@ -26,7 +26,7 @@ enum Disjunct implements Condition { INSTANCE; @Override - public Condition and(Condition other) { + public Condition and(Expression other) { return INSTANCE; } diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/Expressions.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/Expressions.java index db6a348ec5..90538db7f8 100644 --- a/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/Expressions.java +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/Expressions.java @@ -83,9 +83,14 @@ public static Expression of(List columns) { return new TupleExpression(columns); } + public static Expression nest(Expression expression) { + return new NestedExpression(expression); + } + // Utility constructor. private Expressions() {} + static public class SimpleExpression extends AbstractSegment implements Expression { private final String expression; diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/MultipleCondition.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/MultipleCondition.java index 41b8b02caf..215e59374c 100644 --- a/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/MultipleCondition.java +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/MultipleCondition.java @@ -27,10 +27,10 @@ */ public abstract class MultipleCondition extends AbstractSegment implements Condition { - private final List conditions; + private final List conditions; private final String delimiter; - MultipleCondition(String delimiter, Condition... conditions) { + MultipleCondition(String delimiter, Expression... conditions) { super(conditions); @@ -38,7 +38,7 @@ public abstract class MultipleCondition extends AbstractSegment implements Condi this.conditions = Arrays.asList(conditions); } - public List getConditions() { + public List getConditions() { return conditions; } diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/MultipleExpression.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/MultipleExpression.java new file mode 100644 index 0000000000..540031e84c --- /dev/null +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/MultipleExpression.java @@ -0,0 +1,53 @@ +/* + * Copyright 2019-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.sql; + +import java.util.Arrays; +import java.util.List; +import java.util.StringJoiner; + +/** + * Wrapper for multiple {@link Expression}s. + * + * @author Mark Paluch + * @author Jens Schauder + * @since 4.0 + */ +public abstract class MultipleExpression extends AbstractSegment { + + private final List expressions; + private final String delimiter; + + MultipleExpression(String delimiter, Expression... expressions) { + + super(expressions); + + this.delimiter = delimiter; + this.expressions = Arrays.asList(expressions); + } + + public List getExpressions() { + return expressions; + } + + @Override + public String toString() { + + StringJoiner joiner = new StringJoiner(delimiter); + expressions.forEach(c -> joiner.add(c.toString())); + return joiner.toString(); + } +} diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/NestedCondition.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/NestedCondition.java index 9c3711bc10..99e372b0b9 100644 --- a/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/NestedCondition.java +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/NestedCondition.java @@ -21,10 +21,10 @@ * @author Mark Paluch * @since 2.0 */ -public class NestedCondition extends MultipleCondition implements Condition { +public class NestedCondition extends NestedExpression implements Condition { NestedCondition(Condition condition) { - super("", condition); + super(condition); } @Override diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/NestedExpression.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/NestedExpression.java new file mode 100644 index 0000000000..63378d214b --- /dev/null +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/NestedExpression.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.sql; + +/** + * Condition group wrapping a nested {@link Condition} with parentheses. + * + * @author Mark Paluch + * @since 2.0 + */ +public class NestedExpression extends MultipleExpression implements Expression { + + NestedExpression(Expression expression) { + super("", expression); + } + + @Override + public String toString() { + return "(" + super.toString() + ")"; + } +} diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/OperatorExpression.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/OperatorExpression.java new file mode 100644 index 0000000000..c3ca47a3f4 --- /dev/null +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/OperatorExpression.java @@ -0,0 +1,108 @@ +/* + * Copyright 2019-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.sql; + +import org.springframework.util.Assert; + +/** + * Applying an operator to a source {@link Expression}. + *

+ * Results in a rendered condition: {@code } (e.g. {@code col -> index}. + *

+ * + * @author Mark Paluch + * @since 4.0 + */ +public class OperatorExpression extends AbstractSegment implements Expression { + + private final Expression left; + private final String operator; + private final Expression right; + + protected OperatorExpression(Expression left, String operator, Expression right) { + + super(left, right); + + this.left = left; + this.operator = operator; + this.right = right; + } + + /** + * Creates a new {@link OperatorExpression} {@link Condition} given two {@link Expression}s. + * + * @param leftColumnOrExpression the left {@link Expression}. + * @param operator the operator. + * @param rightColumnOrExpression the right {@link Expression}. + * @return the {@link OperatorExpression} condition. + */ + public static OperatorExpression create(Expression leftColumnOrExpression, String operator, + Expression rightColumnOrExpression) { + + Assert.notNull(leftColumnOrExpression, "Left expression must not be null"); + Assert.notNull(operator, "Comparator must not be null"); + Assert.notNull(rightColumnOrExpression, "Right expression must not be null"); + + return new OperatorExpression(leftColumnOrExpression, operator, rightColumnOrExpression); + } + + /** + * Creates a new {@link OperatorExpression} from simple {@literal StringP} arguments + * + * @param unqualifiedColumnName gets turned in a {@link Expressions#just(String)} and is expected to be an unqualified + * unique column name but also could be an verbatim expression. Must not be {@literal null}. + * @param operator must not be {@literal null}. + * @param rightValue is considered a {@link Literal}. Must not be {@literal null}. + * @return a new {@literal Comparison} of the first with the third argument using the second argument as comparison + * operator. Guaranteed to be not {@literal null}. + * @since 2.3 + */ + public static OperatorExpression create(String unqualifiedColumnName, String operator, Object rightValue) { + + Assert.notNull(unqualifiedColumnName, "UnqualifiedColumnName must not be null"); + Assert.notNull(operator, "Comparator must not be null"); + Assert.notNull(rightValue, "RightValue must not be null"); + + return new OperatorExpression(Expressions.just(unqualifiedColumnName), operator, SQL.literalOf(rightValue)); + } + + /** + * @return the left {@link Expression}. + */ + public Expression getLeft() { + return left; + } + + /** + * @return the operator. + */ + public String getOperator() { + return operator; + } + + /** + * @return the right {@link Expression}. + */ + public Expression getRight() { + return right; + } + + @Override + public String toString() { + return left + " " + operator + " " + right; + } + +} diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/OrCondition.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/OrCondition.java index aef90388c8..ad2e65838b 100644 --- a/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/OrCondition.java +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/OrCondition.java @@ -24,7 +24,7 @@ */ public class OrCondition extends MultipleCondition { - OrCondition(Condition... conditions) { + OrCondition(Expression... conditions) { super(" OR ", conditions); } } diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/PostfixExpression.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/PostfixExpression.java new file mode 100644 index 0000000000..2bae9996bd --- /dev/null +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/PostfixExpression.java @@ -0,0 +1,44 @@ +/* + * Copyright 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.sql; + +/** + * @author Mark Paluch + */ +public class PostfixExpression extends AbstractSegment implements Expression { + + private final Expression expression; + private final Expression postfix; + + public PostfixExpression(Expression expression, Expression postfix) { + super(expression, postfix); + this.expression = expression; + this.postfix = postfix; + } + + public Expression getExpression() { + return expression; + } + + public Expression getPostfix() { + return postfix; + } + + @Override + public String toString() { + return expression.toString() + postfix; + } +} diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/SelectBuilder.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/SelectBuilder.java index 8552d09a8b..4241daa108 100644 --- a/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/SelectBuilder.java +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/SelectBuilder.java @@ -423,6 +423,17 @@ interface SelectOrdered extends SelectLock, BuildSelect { */ interface SelectWhere extends SelectOrdered, SelectLock, BuildSelect { + /** + * Apply a {@code WHERE} clause. + * + * @param expression the {@code WHERE} condition expression. + * @return {@code this} builder. + * @since 4.0 + * @see Where + * @see Expression + */ + SelectWhereAndOr where(Expression expression); + /** * Apply a {@code WHERE} clause. * @@ -446,7 +457,7 @@ interface SelectWhereAndOr extends SelectOrdered, SelectLock, BuildSelect { * @return {@code this} builder. * @see Condition#and(Condition) */ - SelectWhereAndOr and(Condition condition); + SelectWhereAndOr and(Expression condition); /** * Combine the previous {@code WHERE} {@link Condition} using {@code OR}. @@ -455,7 +466,7 @@ interface SelectWhereAndOr extends SelectOrdered, SelectLock, BuildSelect { * @return {@code this} builder. * @see Condition#or(Condition) */ - SelectWhereAndOr or(Condition condition); + SelectWhereAndOr or(Expression condition); } /** diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/SimpleFunction.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/SimpleFunction.java index 2ec0913e01..db2a88161c 100644 --- a/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/SimpleFunction.java +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/SimpleFunction.java @@ -15,11 +15,9 @@ */ package org.springframework.data.relational.core.sql; -import java.util.Collections; import java.util.List; import org.springframework.util.Assert; -import org.springframework.util.StringUtils; /** * Simple function accepting one or more {@link Expression}s. @@ -27,17 +25,10 @@ * @author Mark Paluch * @since 1.1 */ -public class SimpleFunction extends AbstractSegment implements Expression { - - private final String functionName; - private final List expressions; +public class SimpleFunction extends BaseFunction implements Expression { private SimpleFunction(String functionName, List expressions) { - - super(expressions.toArray(new Expression[0])); - - this.functionName = functionName; - this.expressions = expressions; + super(functionName, "(", ")", expressions); } /** @@ -55,68 +46,4 @@ public static SimpleFunction create(String functionName, List getExpressions() { - return Collections.unmodifiableList(expressions); - } - - @Override - public String toString() { - return functionName + "(" + StringUtils.collectionToDelimitedString(expressions, ", ") + ")"; - } - - /** - * {@link Aliased} {@link SimpleFunction} implementation. - */ - static class AliasedFunction extends SimpleFunction implements Aliased { - - private final SqlIdentifier alias; - - AliasedFunction(String functionName, List expressions, SqlIdentifier alias) { - super(functionName, expressions); - this.alias = alias; - } - - @Override - public SqlIdentifier getAlias() { - return alias; - } - } } diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/Unrestricted.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/Unrestricted.java index d66e955bd6..a616845dba 100644 --- a/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/Unrestricted.java +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/Unrestricted.java @@ -26,13 +26,13 @@ enum Unrestricted implements Condition { INSTANCE; @Override - public Condition and(Condition other) { - return other; + public Condition and(Expression other) { + return ConditionWrapper.of(other); } @Override - public Condition or(Condition other) { - return other; + public Condition or(Expression other) { + return ConditionWrapper.of(other); } @Override diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/Where.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/Where.java index c9860b62e2..c1b904b01b 100644 --- a/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/Where.java +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/Where.java @@ -23,9 +23,9 @@ */ public class Where extends AbstractSegment { - private final Condition condition; + private final Expression condition; - Where(Condition condition) { + Where(Expression condition) { super(condition); diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/AnalyticFunctionVisitor.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/AnalyticFunctionVisitor.java index c14e04b277..e8afbe3555 100644 --- a/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/AnalyticFunctionVisitor.java +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/AnalyticFunctionVisitor.java @@ -16,8 +16,8 @@ package org.springframework.data.relational.core.sql.render; import org.springframework.data.relational.core.sql.AnalyticFunction; +import org.springframework.data.relational.core.sql.BaseFunction; import org.springframework.data.relational.core.sql.OrderBy; -import org.springframework.data.relational.core.sql.SimpleFunction; import org.springframework.data.relational.core.sql.Visitable; import org.springframework.lang.Nullable; @@ -42,9 +42,9 @@ class AnalyticFunctionVisitor extends TypedSingleConditionRenderSupport implements PartRenderer { + + private final RenderContext context; + private final StringBuilder builder = new StringBuilder(); + private ExpressionVisitor expressionVisitor; + + ArrayIndexExpressionVisitor(RenderContext context) { + this.context = context; + } + + @Override + Delegation enterNested(Visitable segment) { + + expressionVisitor = new ExpressionVisitor(context, ExpressionVisitor.AliasHandling.IGNORE); + return Delegation.delegateTo(expressionVisitor); + } + + @Override + Delegation leaveNested(Visitable segment) { + + Assert.state(expressionVisitor != null, "ExpressionVisitor must not be null"); + + builder.append("[").append(expressionVisitor.getRenderedPart()).append("]"); + return super.leaveNested(segment); + } + + @Override + public CharSequence getRenderedPart() { + + String part = builder.toString(); + builder.setLength(0); + + return part; + } +} diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/ConditionVisitor.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/ConditionVisitor.java index 925915b430..e34d71a7ef 100644 --- a/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/ConditionVisitor.java +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/ConditionVisitor.java @@ -65,16 +65,16 @@ private DelegatingVisitor getDelegation(Condition segment) { return new IsNullVisitor(context, builder::append); } - if (segment instanceof Between) { - return new BetweenVisitor((Between) segment, context, builder::append); + if (segment instanceof Between between) { + return new BetweenVisitor(between, context, builder::append); } - if (segment instanceof Comparison) { - return new ComparisonVisitor(context, (Comparison) segment, builder::append); + if (segment instanceof OperatorExpression oe) { + return new OperatorExpressionVisitor(context, oe, builder::append); } - if (segment instanceof Like) { - return new LikeVisitor((Like) segment, context, builder::append); + if (segment instanceof Like like) { + return new LikeVisitor(like, context, builder::append); } if (segment instanceof In) { @@ -86,8 +86,8 @@ private DelegatingVisitor getDelegation(Condition segment) { } } - if (segment instanceof NestedCondition) { - return new NestedConditionVisitor(context, builder::append); + if (segment instanceof NestedExpression) { + return new NestedExpressionVisitor(context, builder::append); } if (segment instanceof ConstantCondition) { diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/ExpressionVisitor.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/ExpressionVisitor.java index 40c21e1976..51365f4936 100644 --- a/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/ExpressionVisitor.java +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/ExpressionVisitor.java @@ -71,9 +71,9 @@ Delegation enterMatched(Expression segment) { return Delegation.delegateTo(visitor); } - if (segment instanceof SimpleFunction) { + if (segment instanceof BaseFunction) { - SimpleFunctionVisitor visitor = new SimpleFunctionVisitor(context); + FunctionVisitor visitor = new FunctionVisitor(context); partRenderer = visitor; return Delegation.delegateTo(visitor); } @@ -92,6 +92,28 @@ Delegation enterMatched(Expression segment) { return Delegation.delegateTo(visitor); } + if (segment instanceof OperatorExpression oe) { + + OperatorExpressionVisitor visitor = new OperatorExpressionVisitor(context, oe); + partRenderer = visitor; + return Delegation.delegateTo(visitor); + } + + if (segment instanceof PostfixExpression pe) { + + PostfixExpressionVisitor visitor = new PostfixExpressionVisitor(context); + partRenderer = visitor; + return Delegation.delegateTo(visitor); + } + + if (segment instanceof NestedExpression) { + + NestedExpressionVisitor visitor = new NestedExpressionVisitor(context); + + partRenderer = visitor; + return Delegation.delegateTo(visitor); + } + if (segment instanceof Column column) { value = aliasHandling == AliasHandling.USE ? NameRenderer.fullyQualifiedReference(context, column) @@ -110,6 +132,11 @@ Delegation enterMatched(Expression segment) { : NameRenderer.render(context, table); value = renderedTable + ".*"; + } else if (segment instanceof ArrayIndexExpression) { + + ArrayIndexExpressionVisitor visitor = new ArrayIndexExpressionVisitor(context); + partRenderer = visitor; + return Delegation.delegateTo(visitor); } else if (segment instanceof Cast) { CastVisitor visitor = new CastVisitor(context); diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/SimpleFunctionVisitor.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/FunctionVisitor.java similarity index 78% rename from spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/SimpleFunctionVisitor.java rename to spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/FunctionVisitor.java index 8e98a904b6..ae68a4542a 100644 --- a/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/SimpleFunctionVisitor.java +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/FunctionVisitor.java @@ -15,7 +15,7 @@ */ package org.springframework.data.relational.core.sql.render; -import org.springframework.data.relational.core.sql.SimpleFunction; +import org.springframework.data.relational.core.sql.BaseFunction; import org.springframework.data.relational.core.sql.Visitable; /** @@ -26,12 +26,12 @@ * @author Jens Schauder * @since 1.1 */ -class SimpleFunctionVisitor extends TypedSingleConditionRenderSupport implements PartRenderer { +class FunctionVisitor extends TypedSingleConditionRenderSupport implements PartRenderer { private final StringBuilder part = new StringBuilder(); private boolean needsComma = false; - SimpleFunctionVisitor(RenderContext context) { + FunctionVisitor(RenderContext context) { super(context); } @@ -52,17 +52,17 @@ Delegation leaveNested(Visitable segment) { } @Override - Delegation enterMatched(SimpleFunction segment) { + Delegation enterMatched(BaseFunction segment) { - part.append(segment.getFunctionName()).append("("); + part.append(segment.getFunctionName()).append(segment.getBeforeArgs()); return super.enterMatched(segment); } @Override - Delegation leaveMatched(SimpleFunction segment) { + Delegation leaveMatched(BaseFunction segment) { - part.append(")"); + part.append(segment.getAfterArgs()); return super.leaveMatched(segment); } diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/NestedConditionVisitor.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/NestedExpressionVisitor.java similarity index 51% rename from spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/NestedConditionVisitor.java rename to spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/NestedExpressionVisitor.java index 25511b22da..c49461981b 100644 --- a/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/NestedConditionVisitor.java +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/NestedExpressionVisitor.java @@ -16,29 +16,42 @@ package org.springframework.data.relational.core.sql.render; import org.springframework.data.relational.core.sql.Condition; -import org.springframework.data.relational.core.sql.NestedCondition; +import org.springframework.data.relational.core.sql.Expression; +import org.springframework.data.relational.core.sql.NestedExpression; import org.springframework.data.relational.core.sql.Visitable; import org.springframework.lang.Nullable; /** - * Renderer for {@link NestedCondition}. Uses a {@link RenderTarget} to call back for render results. + * Renderer for {@link NestedExpression}. Can use a {@link RenderTarget} to call back for render results. * * @author Mark Paluch - * @since 2.0 + * @since 4.0 */ -class NestedConditionVisitor extends TypedSubtreeVisitor { +class NestedExpressionVisitor extends TypedSubtreeVisitor implements PartRenderer { private final RenderContext context; - private final RenderTarget target; + private final @Nullable RenderTarget target; private @Nullable ConditionVisitor conditionVisitor; + private @Nullable ExpressionVisitor expressionVisitor; + private final StringBuilder builder = new StringBuilder(); - NestedConditionVisitor(RenderContext context, RenderTarget target) { + NestedExpressionVisitor(RenderContext context) { + this.context = context; + this.target = null; + } + NestedExpressionVisitor(RenderContext context, RenderTarget target) { this.context = context; this.target = target; } + @Override + Delegation enterMatched(NestedExpression segment) { + builder.setLength(0); + return super.enterMatched(segment); + } + @Override Delegation enterNested(Visitable segment) { @@ -54,6 +67,10 @@ private DelegatingVisitor getDelegation(Visitable segment) { return conditionVisitor = new ConditionVisitor(context); } + if (segment instanceof Expression) { + return expressionVisitor = new ExpressionVisitor(context, ExpressionVisitor.AliasHandling.IGNORE); + } + return null; } @@ -62,10 +79,35 @@ Delegation leaveNested(Visitable segment) { if (conditionVisitor != null) { - target.onRendered("(" + conditionVisitor.getRenderedPart() + ")"); + String part = "(" + conditionVisitor.getRenderedPart() + ")"; + if (target != null) { + target.onRendered(part); + } else { + builder.append(part); + } + conditionVisitor = null; + } + + if (expressionVisitor != null) { + + String part = "(" + expressionVisitor.getRenderedPart() + ")"; + if (target != null) { + target.onRendered(part); + } else { + builder.append(part); + } conditionVisitor = null; } return super.leaveNested(segment); } + + @Override + public CharSequence getRenderedPart() { + + String part = builder.toString(); + builder.setLength(0); + + return part; + } } diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/ComparisonVisitor.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/OperatorExpressionVisitor.java similarity index 64% rename from spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/ComparisonVisitor.java rename to spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/OperatorExpressionVisitor.java index f1e5a12e57..df2341a46a 100644 --- a/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/ComparisonVisitor.java +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/OperatorExpressionVisitor.java @@ -15,34 +15,41 @@ */ package org.springframework.data.relational.core.sql.render; -import org.springframework.data.relational.core.sql.Comparison; -import org.springframework.data.relational.core.sql.Condition; import org.springframework.data.relational.core.sql.Expression; +import org.springframework.data.relational.core.sql.OperatorExpression; import org.springframework.data.relational.core.sql.Visitable; import org.springframework.lang.Nullable; /** - * {@link org.springframework.data.relational.core.sql.Visitor} rendering comparison {@link Condition}. Uses a - * {@link RenderTarget} to call back for render results. + * {@link org.springframework.data.relational.core.sql.Visitor} rendering comparison {@link OperatorExpression}. * * @author Mark Paluch * @author Jens Schauder * @since 1.1 - * @see Comparison + * @see OperatorExpression */ -class ComparisonVisitor extends FilteredSubtreeVisitor { +class OperatorExpressionVisitor extends FilteredSubtreeVisitor implements PartRenderer { private final RenderContext context; - private final Comparison condition; - private final RenderTarget target; + private final OperatorExpression expression; + private @Nullable final RenderTarget target; private final StringBuilder part = new StringBuilder(); private @Nullable PartRenderer current; - ComparisonVisitor(RenderContext context, Comparison condition, RenderTarget target) { + OperatorExpressionVisitor(RenderContext context, OperatorExpression expression) { - super(it -> it == condition); + super(it -> it == expression); - this.condition = condition; + this.expression = expression; + this.target = null; + this.context = context; + } + + OperatorExpressionVisitor(RenderContext context, OperatorExpression expression, RenderTarget target) { + + super(it -> it == expression); + + this.expression = expression; this.target = target; this.context = context; } @@ -63,8 +70,8 @@ Delegation enterNested(Visitable segment) { Delegation leaveNested(Visitable segment) { if (current != null) { - if (part.length() != 0) { - part.append(' ').append(condition.getComparator()).append(' '); + if (!part.isEmpty()) { + part.append(' ').append(expression.getOperator()).append(' '); } part.append(current.getRenderedPart()); @@ -77,8 +84,19 @@ Delegation leaveNested(Visitable segment) { @Override Delegation leaveMatched(Visitable segment) { - target.onRendered(part); + if (target != null) { + target.onRendered(part); + } return super.leaveMatched(segment); } + + @Override + public CharSequence getRenderedPart() { + + String part = this.part.toString(); + this.part.setLength(0); + + return part; + } } diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/OrderByClauseVisitor.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/OrderByClauseVisitor.java index 1fc2594b8d..3f147e48f6 100644 --- a/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/OrderByClauseVisitor.java +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/OrderByClauseVisitor.java @@ -16,11 +16,11 @@ package org.springframework.data.relational.core.sql.render; +import org.springframework.data.relational.core.sql.BaseFunction; import org.springframework.data.relational.core.sql.CaseExpression; import org.springframework.data.relational.core.sql.Column; import org.springframework.data.relational.core.sql.Expressions; import org.springframework.data.relational.core.sql.OrderByField; -import org.springframework.data.relational.core.sql.SimpleFunction; import org.springframework.data.relational.core.sql.Visitable; import org.springframework.lang.Nullable; @@ -82,9 +82,9 @@ Delegation leaveMatched(OrderByField segment) { @Override Delegation enterNested(Visitable segment) { - if (segment instanceof SimpleFunction) { - delegate = new SimpleFunctionVisitor(context); - return Delegation.delegateTo((SimpleFunctionVisitor) delegate); + if (segment instanceof BaseFunction) { + delegate = new FunctionVisitor(context); + return Delegation.delegateTo((FunctionVisitor) delegate); } if (segment instanceof Expressions.SimpleExpression || segment instanceof CaseExpression) { @@ -98,7 +98,7 @@ Delegation enterNested(Visitable segment) { @Override Delegation leaveNested(Visitable segment) { - if (delegate instanceof SimpleFunctionVisitor || delegate instanceof ExpressionVisitor) { + if (delegate instanceof FunctionVisitor || delegate instanceof ExpressionVisitor) { builder.append(delegate.getRenderedPart()); delegate = null; } diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/PostfixExpressionVisitor.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/PostfixExpressionVisitor.java new file mode 100644 index 0000000000..dc6f55bd99 --- /dev/null +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/PostfixExpressionVisitor.java @@ -0,0 +1,64 @@ +/* + * Copyright 2021-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.sql.render; + +import org.springframework.data.relational.core.sql.PostfixExpression; +import org.springframework.data.relational.core.sql.Visitable; +import org.springframework.lang.Nullable; +import org.springframework.util.Assert; + +/** + * Renders an array index expression, by delegating to an {@link ExpressionVisitor} and building the expression out of + * the rendered parts. + * + * @author Mark Paluch + * @since 4.0 + */ +class PostfixExpressionVisitor extends TypedSubtreeVisitor implements PartRenderer { + + private final RenderContext context; + private final StringBuilder builder = new StringBuilder(); + private @Nullable ExpressionVisitor expressionVisitor; + + PostfixExpressionVisitor(RenderContext context) { + this.context = context; + } + + @Override + Delegation enterNested(Visitable segment) { + + expressionVisitor = new ExpressionVisitor(context, ExpressionVisitor.AliasHandling.IGNORE); + return Delegation.delegateTo(expressionVisitor); + } + + @Override + Delegation leaveNested(Visitable segment) { + + Assert.state(expressionVisitor != null, "ExpressionVisitor must not be null"); + + builder.append(expressionVisitor.getRenderedPart()); + return super.leaveNested(segment); + } + + @Override + public CharSequence getRenderedPart() { + + String part = builder.toString(); + builder.setLength(0); + + return part; + } +} diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/SelectListVisitor.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/SelectListVisitor.java index b52493a1bd..c590fae903 100644 --- a/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/SelectListVisitor.java +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/SelectListVisitor.java @@ -16,6 +16,7 @@ package org.springframework.data.relational.core.sql.render; import org.springframework.data.relational.core.sql.Aliased; +import org.springframework.data.relational.core.sql.Condition; import org.springframework.data.relational.core.sql.Expression; import org.springframework.data.relational.core.sql.SelectList; import org.springframework.data.relational.core.sql.Visitable; @@ -34,12 +35,14 @@ class SelectListVisitor extends TypedSubtreeVisitor implements PartR private final RenderTarget target; private boolean requiresComma = false; private ExpressionVisitor expressionVisitor; + private final ConditionVisitor conditionVisitor; // subelements. SelectListVisitor(RenderContext context, RenderTarget target) { this.context = context; this.target = target; + this.conditionVisitor = new ConditionVisitor(context); this.expressionVisitor = new ExpressionVisitor(context, ExpressionVisitor.AliasHandling.IGNORE); } @@ -50,6 +53,11 @@ Delegation enterNested(Visitable segment) { builder.append(", "); requiresComma = false; } + + if (segment instanceof Condition) { + return Delegation.delegateTo(conditionVisitor); + } + if (segment instanceof Expression) { return Delegation.delegateTo(expressionVisitor); } @@ -67,7 +75,11 @@ Delegation leaveMatched(SelectList segment) { @Override Delegation leaveNested(Visitable segment) { - if (segment instanceof Expression) { + if (segment instanceof Condition) { + + builder.append(conditionVisitor.getRenderedPart()); + requiresComma = true; + } else if (segment instanceof Expression) { builder.append(expressionVisitor.getRenderedPart()); requiresComma = true; diff --git a/spring-data-relational/src/test/java/org/springframework/data/relational/core/query/PgSqlUnitTests.java b/spring-data-relational/src/test/java/org/springframework/data/relational/core/query/PgSqlUnitTests.java new file mode 100644 index 0000000000..d46baba60f --- /dev/null +++ b/spring-data-relational/src/test/java/org/springframework/data/relational/core/query/PgSqlUnitTests.java @@ -0,0 +1,116 @@ +/* + * Copyright 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.assertj.core.api.Assertions.*; + +import org.junit.jupiter.api.Test; + +import org.springframework.data.domain.Vector; +import org.springframework.data.relational.core.binding.BindMarkersFactory; +import org.springframework.data.relational.core.conversion.MappingRelationalConverter; +import org.springframework.data.relational.core.mapping.Column; +import org.springframework.data.relational.core.mapping.RelationalMappingContext; +import org.springframework.data.relational.core.sql.Expression; +import org.springframework.data.relational.core.sql.Select; +import org.springframework.data.relational.core.sql.Table; +import org.springframework.data.relational.core.sql.render.SqlRenderer; + +/** + * Unit tests for {@link PgSql} query methods. + * + * @author Mark Paluch + */ +class PgSqlUnitTests { + + RelationalMappingContext context = new RelationalMappingContext(); + MappingRelationalConverter converter = new MappingRelationalConverter(context); + Table table = Table.create("with_embedding"); + SimpleQueryRenderContext renderContext = new SimpleQueryRenderContext(table, BindMarkersFactory.named(":", "p", 32), + converter, context.getRequiredPersistentEntity(WithEmbedding.class)); + + @Test + void distanceLessThan() { + // distanceOf("embedding").l2() + + Criteria embedding = PgSql + .where("embedding", it -> it.distanceTo(Vector.of(1, 2, 3), PgSql.VectorSearchFunctions.Distances::cosine)) + .lessThan(0.8); + + String sql = toSql(embedding); + + assertThat(sql).contains("with_embedding.the_embedding <=> :p0 < :p1"); + } + + @Test + void fieldIsActiveIsTrue() { + + Criteria booleanJsonFieldIsTrue = PgSql.where("properties", it -> it.field("active").asBoolean()).isTrue(); + + String sql = toSql(booleanJsonFieldIsTrue); + + assertThat(sql).contains("(with_embedding.properties -> :p0)::boolean = :p1"); + } + + @Test + void containsArray() { + + // SqlIdentifier to refer to columns? + Criteria arrayContains = PgSql.where("tags").json().containsAll("electronics", "gaming"); + + String sql = toSql(arrayContains); + + assertThat(sql).contains("with_embedding.tags ?& array[:p0, :p1]"); + } + + @Test + void shouldRenderSourceFunction() { + + // SqlIdentifier to refer to columns? + Criteria arrayContains = PgSql.where(PgSql.function("array_ndims", CriteriaSource.ofDotPath("someArray"))) + .greaterThan(2); + + String sql = toSql(arrayContains); + + assertThat(sql).contains("array_ndims(with_embedding.some_array) > :p0"); + } + + @Test + void arrayContains() { + + Criteria someArrayContains = PgSql.where("someArray").contains(PgSql.arrays().arrayOf("electronics", "gaming")); + + String sql = toSql(someArrayContains); + + assertThat(sql).contains("with_embedding.some_array @> array[:p0, :p1]"); + } + + private String toSql(Criteria criteria) { + + QueryExpression.QueryRenderContext contextualized = criteria.getExpression().contextualize(renderContext); + Expression expression = criteria.getExpression().render(contextualized); + + return SqlRenderer.toString(Select.builder().select(expression).from(table).where(expression).build()); + } + + static class WithEmbedding { + + @Column("the_embedding") Vector embedding; + + String[] someArray; + + } +} From cadd0921e1e6ec244d55a0d7d3e0b2127781a1ce Mon Sep 17 00:00:00 2001 From: Mark Paluch Date: Tue, 24 Jun 2025 15:25:52 +0200 Subject: [PATCH 3/8] Extend Dialect support, refine Criteria creation. --- .../data/relational/core/query/Criteria.java | 71 +++- .../relational/core/query/CriteriaSource.java | 4 +- .../core/query/CriteriaSources.java | 2 +- .../data/relational/core/query/PgSql.java | 395 ++++++++++++++++-- .../data/relational/core/query/PgSqlImpl.java | 182 +++++--- .../core/query/QueryExpression.java | 3 + .../relational/core/query/PgSqlUnitTests.java | 44 +- 7 files changed, 584 insertions(+), 117 deletions(-) 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 eb7f9101e0..178c25f723 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 @@ -79,13 +79,14 @@ protected Criteria(SqlIdentifier column, Comparator comparator, @Nullable Object this(null, Combinator.INITIAL, Collections.emptyList(), null, column, comparator, value, false); } - protected Criteria(QueryExpression queryExpression, SqlIdentifier column, Comparator comparator, + protected Criteria(QueryExpression queryExpression, @Nullable SqlIdentifier column, Comparator comparator, @Nullable Object value) { this(null, Combinator.INITIAL, Collections.emptyList(), queryExpression, column, comparator, value, false); } protected Criteria(@Nullable Criteria previous, Combinator combinator, List group, - @Nullable SqlIdentifier column, @Nullable Comparator comparator, @Nullable Object value) { + @Nullable QueryExpression queryExpression, @Nullable SqlIdentifier column, @Nullable Comparator comparator, + @Nullable Object value) { this(previous, combinator, group, null, column, comparator, value, false); } @@ -168,7 +169,7 @@ public static CriteriaStep where(String column) { Assert.hasText(column, "Column name must not be null or empty"); - return new DefaultCriteriaStep(SqlIdentifier.unquoted(column)); + return new DefaultCriteriaStep(CriteriaSource.ofColumn(column), SqlIdentifier.unquoted(column)); } /** @@ -182,10 +183,30 @@ public CriteriaStep and(String column) { Assert.hasText(column, "Column name must not be null or empty"); SqlIdentifier identifier = SqlIdentifier.unquoted(column); - return new DefaultCriteriaStep(identifier) { + CriteriaSource lhs = CriteriaSource.ofColumn(column); + return new DefaultCriteriaStep(lhs, identifier) { @Override protected Criteria createCriteria(Comparator comparator, @Nullable Object value) { - return new Criteria(Criteria.this, Combinator.AND, Collections.emptyList(), identifier, comparator, value); + return new Criteria(Criteria.this, Combinator.AND, Collections.emptyList(), lhs, identifier, comparator, value); + } + }; + } + + /** + * Create a new {@link Criteria} and combine it with {@code AND} using the provided {@code expression}. + * + * @param expression must not be {@literal null} or empty. + * @return a new {@link CriteriaStep} object to complete the next {@link Criteria}. + */ + public CriteriaStep and(QueryExpression expression) { + + Assert.notNull(expression, "Query expression must not be null"); + + return new DefaultCriteriaStep(expression) { + @Override + protected Criteria createCriteria(Comparator comparator, @Nullable Object value) { + return new Criteria(Criteria.this, Combinator.AND, Collections.emptyList(), expression, null, comparator, + value); } }; } @@ -229,10 +250,29 @@ public CriteriaStep or(String column) { Assert.hasText(column, "Column name must not be null or empty"); SqlIdentifier identifier = SqlIdentifier.unquoted(column); - return new DefaultCriteriaStep(identifier) { + CriteriaSource lhs = CriteriaSource.ofColumn(column); + return new DefaultCriteriaStep(lhs, identifier) { + @Override + protected Criteria createCriteria(Comparator comparator, @Nullable Object value) { + return new Criteria(Criteria.this, Combinator.OR, Collections.emptyList(), lhs, identifier, comparator, value); + } + }; + } + + /** + * Create a new {@link Criteria} and combine it with {@code OR} using the provided {@code expression}. + * + * @param expression must not be {@literal null} or empty. + * @return a new {@link CriteriaStep} object to complete the next {@link Criteria}. + */ + public CriteriaStep or(QueryExpression expression) { + + Assert.notNull(expression, "Query expression must not be null"); + + return new DefaultCriteriaStep(expression) { @Override protected Criteria createCriteria(Comparator comparator, @Nullable Object value) { - return new Criteria(Criteria.this, Combinator.OR, Collections.emptyList(), identifier, comparator, value); + return new Criteria(Criteria.this, Combinator.OR, Collections.emptyList(), expression, null, comparator, value); } }; } @@ -681,9 +721,16 @@ public interface CriteriaStep { */ protected static class DefaultCriteriaStep implements CriteriaStep { - private final SqlIdentifier property; + private final QueryExpression lhs; + private final @Nullable SqlIdentifier property; - protected DefaultCriteriaStep(SqlIdentifier property) { + protected DefaultCriteriaStep(QueryExpression lhs) { + this.lhs = lhs; + this.property = null; + } + + DefaultCriteriaStep(QueryExpression lhs, SqlIdentifier property) { + this.lhs = lhs; this.property = property; } @@ -833,8 +880,12 @@ public Criteria isFalse() { return createCriteria(Comparator.IS_FALSE, false); } + protected QueryExpression getLhs() { + return lhs; + } + protected Criteria createCriteria(Comparator comparator, @Nullable Object value) { - return new Criteria(this.property, comparator, value); + return new Criteria(this.lhs, this.property, comparator, value); } } } diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/query/CriteriaSource.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/query/CriteriaSource.java index 1ed30eaf15..35776f5823 100644 --- a/spring-data-relational/src/main/java/org/springframework/data/relational/core/query/CriteriaSource.java +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/query/CriteriaSource.java @@ -22,8 +22,8 @@ */ public interface CriteriaSource extends QueryExpression { - static CriteriaSource ofDotPath(String name) { - return new CriteriaSources.DotPath(name); + static CriteriaSource ofColumn(String name) { + return new CriteriaSources.Column(name); } static CriteriaSource of(SqlIdentifier identifier) { diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/query/CriteriaSources.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/query/CriteriaSources.java index f0ff65e809..dd900c14ee 100644 --- a/spring-data-relational/src/main/java/org/springframework/data/relational/core/query/CriteriaSources.java +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/query/CriteriaSources.java @@ -22,7 +22,7 @@ * @author Mark Paluch */ class CriteriaSources { - public static final record DotPath(String name) implements CriteriaSource { + public static final record Column(String name) implements CriteriaSource { @Override public QueryRenderContext contextualize(QueryRenderContext context) { diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/query/PgSql.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/query/PgSql.java index 44c46e7953..f205d41b53 100644 --- a/spring-data-relational/src/main/java/org/springframework/data/relational/core/query/PgSql.java +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/query/PgSql.java @@ -16,10 +16,16 @@ package org.springframework.data.relational.core.query; import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Map; import java.util.function.Function; import org.springframework.data.domain.ScoringFunction; import org.springframework.data.domain.Vector; +import org.springframework.data.relational.core.query.PgSqlImpl.ValuesExpression; +import org.springframework.lang.Nullable; +import org.springframework.util.Assert; /** * @author Mark Paluch @@ -65,10 +71,9 @@ public static PgCriteria.PostgresCriteriaStep where(String column) { * @return */ public static PgCriteria.PostgresCriteriaStep where(String column, - Function wrappingFunction) { + Function wrappingFunction) { - PgSqlImpl.DefaultFunctions functions = new PgSqlImpl.DefaultFunctions(new CriteriaSources.DotPath(column)); - return where(wrappingFunction.apply(functions)); + return where(new CriteriaSources.Column(column), wrappingFunction); } /** @@ -82,6 +87,27 @@ public static PgCriteria.PostgresCriteriaStep where(QueryExpression expression) return new PgSqlImpl.DefaultPostgresCriteriaStep(expression); } + /** + * Entry point for creating a {@link PgCriteria.PostgresCriteriaStep} for the given {@link QueryExpression} that shall + * be wrapped by a wrapping function to process the column contents before its use in a downstream condition or + * expression. + * + * @param expression + * @param wrappingFunction + * @return + */ + public static PgCriteria.PostgresCriteriaStep where(QueryExpression expression, + Function wrappingFunction) { + + PgSqlImpl.DefaultOperators functions = new PgSqlImpl.DefaultOperators(expression); + return where(wrappingFunction.apply(functions)); + } + + /** + * Entrypoint for array functions. + * + * @return + */ public static ArrayFunctions arrays() { return new ArrayFunctions() { @@ -92,6 +118,27 @@ public QueryExpression arrayOf(Object... values) { }; } + /** + * Entrypoint for JSON functions. + * + * @return + */ + public static JsonFunctions json() { + + return new JsonFunctions() { + + @Override + public QueryExpression jsonOf(Map jsonObject) { + return new PgSqlImpl.JsonExpression(jsonObject, "json"); + } + + @Override + public QueryExpression jsonbOf(Map jsonObject) { + return new PgSqlImpl.JsonExpression(jsonObject, "jsonb"); + } + }; + } + /** * Postgres-specific {@link Criteria} implementation that provides access to Postgres-specific operators and * functions. @@ -102,33 +149,149 @@ protected PgCriteria(QueryExpression source) { super(source); } + protected PgCriteria(@Nullable Criteria previous, Combinator combinator, List group, + @Nullable QueryExpression queryExpression, @Nullable Comparator comparator, @Nullable Object value) { + super(previous, combinator, group, queryExpression, null, comparator, value); + } + + protected PgCriteria(@Nullable Criteria previous, Combinator combinator, List group, + @Nullable QueryExpression queryExpression, @Nullable Comparator comparator, @Nullable Object value, + boolean ignoreCase) { + super(previous, combinator, group, queryExpression, null, comparator, value, ignoreCase); + } + + @Override + public PgCriteria.PostgresCriteriaStep and(String column) { + + Assert.hasText(column, "Column name must not be null or empty"); + + return and(CriteriaSource.ofColumn(column)); + } + + @Override + public PgCriteria.PostgresCriteriaStep and(QueryExpression expression) { + + Assert.notNull(expression, "Query expression must not be null"); + + return createStep(expression, Combinator.AND); + } + + @Override + public PgCriteria.PostgresCriteriaStep or(String column) { + + Assert.hasText(column, "Column name must not be null or empty"); + + return or(CriteriaSource.ofColumn(column)); + } + + @Override + public PgCriteria.PostgresCriteriaStep or(QueryExpression expression) { + + Assert.notNull(expression, "Query expression must not be null"); + + return createStep(expression, Combinator.OR); + } + + private PgSqlImpl.DefaultPostgresCriteriaStep createStep(QueryExpression expression, Combinator combinator) { + + return new PgSqlImpl.DefaultPostgresCriteriaStep(expression) { + + @Override + protected PgCriteria createCriteria(Comparator comparator, @Nullable Object value) { + return new PgCriteria(PgCriteria.this, combinator, Collections.emptyList(), expression, comparator, value); + } + + @Override + protected PgCriteria createCriteria(QueryExpression queryExpression) { + return new PgCriteria(PgCriteria.this, combinator, Collections.emptyList(), expression, null, null); + } + }; + } + + /** + * Interface providing a fluent API builder methods to build a {@link Criteria}. + */ public interface PostgresCriteriaStep extends CriteriaStep { /** - * {@code ?} operator for JSONB containment. + * Array criteria steps. * - * @param value * @return */ - PgCriteria exists(Object value); + PostgresArrayCriteriaStep arrays(); /** - * {@code @>} operator for JSONB containment. + * JSON criteria steps. * - * @param value * @return */ - PgCriteria contains(Object value); + PostgresJsonCriteriaStep json(); + + } + + /** + * Fluent Postgres-specific Array criteria API providing access to Array operators and functions. + */ + public interface PostgresArrayCriteriaStep { /** - * {@code &&} operator for array containment. + * Does the first array contain the second, that is, does each element appearing in the second array equal some + * element of the first array using {@code @>}. * - * @param value + * @param expression * @return */ - PgCriteria overlaps(Object value); + PgCriteria contains(QueryExpression expression); - PostgresJsonCriteriaStep json(); + /** + * Does the first array contain the second, that is, does each element appearing in the second array equal some + * element of the first array using {@code @>}. + * + * @param column + * @property + */ + default PgCriteria contains(String column) { + return contains(CriteriaSource.ofColumn(column)); + } + + /** + * Does the first array contain {@code values}, that is, does each element appearing in the second array equal + * some element of the first array using {@code @>}. + * + * @param values + * @property + */ + default PgCriteria contains(Object... values) { + return contains(PgSqlImpl.ArrayExpression.expressionOrWrap(values)); + } + + /** + * Do the arrays overlap, that is, have any elements in common using {@code &&}. + * + * @param expression + * @return + */ + PgCriteria overlaps(QueryExpression expression); + + /** + * Do the arrays overlap, that is, have any elements in common using {@code &&}. + * + * @param column + * @return + */ + default PgCriteria overlaps(String column) { + return overlaps(CriteriaSource.ofColumn(column)); + } + + /** + * Do the arrays overlap, that is, have any elements in common using {@code &&}. + * + * @param values + * @property + */ + default PgCriteria overlaps(Object... values) { + return overlaps(PgSqlImpl.ArrayExpression.expressionOrWrap(values)); + } } @@ -137,18 +300,46 @@ public interface PostgresCriteriaStep extends CriteriaStep { */ public interface PostgresJsonCriteriaStep { - PgCriteria contains(String field); + default PgCriteria exists(String column) { + return exists(CriteriaSource.ofColumn(column)); + } + + /** + * {@code ?} operator for JSONB containment. + * + * @param expression + * @return + */ + PgCriteria exists(QueryExpression expression); + + /** + * {@code ?} operator for JSONB containment. + * + * @param value + * @return + */ + default PgCriteria exists(Object value) { + return exists(new PgSqlImpl.ValueExpression(value)); + } + + default PgCriteria contains(String field) { + return contains(CriteriaSource.ofColumn(field)); + } PgCriteria contains(Object value); + default PgCriteria containsAll(Object... values) { + return containsAll(Arrays.asList(values)); + } + PgCriteria containsAll(Iterable values); - PgCriteria containsAll(Object... values); + default PgCriteria containsAny(Object... values) { + return containsAny(Arrays.asList(values)); + } PgCriteria containsAny(Iterable values); - PgCriteria containsAny(Object... values); - // @? PgCriteria jsonPathMatches(String jsonPath); @@ -158,22 +349,67 @@ public interface PostgresJsonCriteriaStep { } } + /** + * Postgres-specific Array functions for querying array data. + */ + public interface ArrayFunctions { + + /** + * Constructs an {@code ARRAY[…]} expression from the given values. + * + * @param values + * @return an expression that represents a Postgres array of the given values. + */ + QueryExpression arrayOf(Object... values); + + } + + /** + * Postgres-specific JSON functions for querying JSON data. + */ + public interface JsonFunctions { + + /** + * Constructs an {@code JSON} value from the given {@code jsonObject}. + * + * @param jsonObject + * @return a JSONB value that represents the given JSON object. + */ + QueryExpression jsonOf(Map jsonObject); + + /** + * Constructs an {@code JSONB} value from the given {@code jsonObject}. + * + * @param jsonObject + * @return a JSONB value that represents the given JSON object. + */ + QueryExpression jsonbOf(Map jsonObject); + + } + /** * Entrypoint for Postgres-specific functions. */ - public interface Functions extends VectorSearchFunctions, JsonFunctions { + public interface Operators extends ArrayOperators, JsonOperators, VectorSearchOperators { + + /** + * Returns a {@link ArrayOperators} object providing access to array functions. + */ + default ArrayOperators array() { + return this; + } /** - * Returns a {@link JsonFunctions} object providing access to JSON functions. + * Returns a {@link JsonOperators} object providing access to JSON functions. */ - default JsonFunctions json() { + default JsonOperators json() { return this; } /** - * Returns a {@link VectorSearchFunctions} object providing access to pgvector functions. + * Returns a {@link VectorSearchOperators} object providing access to pgvector functions. */ - default VectorSearchFunctions vector() { + default VectorSearchOperators vector() { return this; } @@ -182,7 +418,7 @@ default VectorSearchFunctions vector() { /** * pgvector-specific functions for Postgres Vector Search. */ - public interface VectorSearchFunctions { + public interface VectorSearchOperators { /** * Calculates the distance to the given vector using the specified distance function. @@ -236,25 +472,10 @@ default ScoringFunction of(String operator) { } } - /** - * Postgres-specific Array functions for querying array data. - */ - public interface ArrayFunctions { - - /** - * Constructs an {@code ARRAY[…]} expression from the given values. - * - * @param values - * @return an expression that represents a Postgres array of the given values. - */ - QueryExpression arrayOf(Object... values); - - } - /** * Postgres-specific JSON functions for querying JSON data. */ - public interface JsonFunctions { + public interface JsonOperators { /** * Creates an index expression to extract a value from a JSON array at the given {@code index} using the arrow @@ -275,6 +496,102 @@ public interface JsonFunctions { PostgresQueryExpression field(String field); } + /** + * Postgres-specific Array operators. + */ + public interface ArrayOperators { + + /** + * Does the first array contain the second, that is, does each element appearing in the second array equal some + * element of the first array using {@code @>}. + * + * @param expression + * @return + */ + PostgresQueryExpression contains(QueryExpression expression); + + /** + * Does the first array contain the second, that is, does each element appearing in the second array equal some + * element of the first array using {@code @>}. + * + * @param column + * @property + */ + default PostgresQueryExpression contains(String column) { + return contains(CriteriaSource.ofColumn(column)); + } + + /** + * Does the first array contain {@code values}, that is, does each element appearing in the second array equal some + * element of the first array using {@code @>}. + * + * @param values + * @property + */ + default PostgresQueryExpression contains(Object... values) { + return contains(ValuesExpression.oneOrMany(values)); + } + + /** + * Do the arrays overlap, that is, have any elements in common using {@code &&}. + * + * @param expression + * @return + */ + PostgresQueryExpression overlaps(QueryExpression expression); + + /** + * Do the arrays overlap, that is, have any elements in common using {@code &&}. + * + * @param column + * @return + */ + default PostgresQueryExpression overlaps(String column) { + return overlaps(CriteriaSource.ofColumn(column)); + } + + /** + * Do the arrays overlap, that is, have any elements in common using {@code &&}. + * + * @param values + * @property + */ + default PostgresQueryExpression overlaps(Object... values) { + return overlaps(ValuesExpression.oneOrMany(values)); + } + + /** + * Concatenates this the current array with the given {@code expression} representing the array concatenation + * operator. + * + * @param expression + * @return + */ + PostgresQueryExpression concatWith(QueryExpression expression); + + /** + * Concatenates this the current array with the given {@code property} representing the array concatenation + * operator. + * + * @param column + * @return + */ + default PostgresQueryExpression concatWith(String column) { + return concatWith(CriteriaSource.ofColumn(column)); + } + + /** + * Concatenates this the current array with the given {@code values} representing the array concatenation operator. + * + * @param values + * @property + */ + default PostgresQueryExpression concatWith(Object... values) { + return concatWith(ValuesExpression.oneOrMany(values)); + } + + } + /** * Postgres-specific query expression that allows to append Postgres-specific type casts and array indexing. */ diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/query/PgSqlImpl.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/query/PgSqlImpl.java index 4d58e1e065..085b4d4315 100644 --- a/spring-data-relational/src/main/java/org/springframework/data/relational/core/query/PgSqlImpl.java +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/query/PgSqlImpl.java @@ -17,12 +17,23 @@ import java.util.ArrayList; import java.util.Arrays; +import java.util.Iterator; import java.util.List; +import java.util.Map; import java.util.function.Function; import org.springframework.data.domain.ScoringFunction; import org.springframework.data.domain.Vector; -import org.springframework.data.relational.core.sql.*; +import org.springframework.data.relational.core.sql.ArrayIndexExpression; +import org.springframework.data.relational.core.sql.BaseFunction; +import org.springframework.data.relational.core.sql.BindMarker; +import org.springframework.data.relational.core.sql.Comparison; +import org.springframework.data.relational.core.sql.Expression; +import org.springframework.data.relational.core.sql.Expressions; +import org.springframework.data.relational.core.sql.OperatorExpression; +import org.springframework.data.relational.core.sql.PostfixExpression; +import org.springframework.data.relational.core.sql.SimpleFunction; +import org.springframework.data.relational.core.sql.TupleExpression; import org.springframework.lang.Nullable; /** @@ -36,35 +47,17 @@ class PgSqlImpl { static class DefaultPostgresCriteriaStep extends Criteria.DefaultCriteriaStep implements PgSql.PgCriteria.PostgresCriteriaStep { - private final QueryExpression source; - protected DefaultPostgresCriteriaStep(String propertyName) { - this(CriteriaSource.ofDotPath(propertyName)); + this(CriteriaSource.ofColumn(propertyName)); } protected DefaultPostgresCriteriaStep(QueryExpression source) { - super(SqlIdentifier.unquoted("foo")); - this.source = source; - } - - protected DefaultPostgresCriteriaStep(CriteriaSources.DotPath identifier) { - super(SqlIdentifier.unquoted(identifier.name())); - this.source = identifier; - } - - protected DefaultPostgresCriteriaStep(CriteriaSources.SqlIdentifierSource identifier) { - super(identifier.identifier()); - this.source = identifier; - } - - protected DefaultPostgresCriteriaStep(SqlIdentifier sqlIdentifier) { - super(sqlIdentifier); - this.source = CriteriaSource.of(sqlIdentifier); + super(source); } protected Criteria createCriteria(CriteriaDefinition.Comparator comparator, @Nullable Object value) { - return new PgSql.PgCriteria(new PostgresComparison(source, + return new PgSql.PgCriteria(new PostgresComparison(getLhs(), (comparator == CriteriaDefinition.Comparator.IS_TRUE || comparator == CriteriaDefinition.Comparator.IS_FALSE) ? "=" : comparator.getComparator(), @@ -72,7 +65,7 @@ protected Criteria createCriteria(CriteriaDefinition.Comparator comparator, @Nul } protected PgSql.PgCriteria createCriteria(String operator, Object value) { - return createCriteria(new PostgresComparison(source, operator, + return createCriteria(new PostgresComparison(getLhs(), operator, value instanceof QueryExpression e ? e : new ValueExpression(value))); } @@ -81,18 +74,18 @@ protected PgSql.PgCriteria createCriteria(QueryExpression queryExpression) { } @Override - public PgSql.PgCriteria exists(Object value) { - return createCriteria("?", value); - } - - @Override - public PgSql.PgCriteria contains(Object value) { - return createCriteria("@>", value); - } + public PgSql.PgCriteria.PostgresArrayCriteriaStep arrays() { + return new PgSql.PgCriteria.PostgresArrayCriteriaStep() { + @Override + public PgSql.PgCriteria contains(QueryExpression expression) { + return createCriteria("@>", expression); + } - @Override - public PgSql.PgCriteria overlaps(Object value) { - return createCriteria("?", value); + @Override + public PgSql.PgCriteria overlaps(QueryExpression expression) { + return createCriteria("&&", expression); + } + }; } @Override @@ -100,33 +93,23 @@ public PgSql.PgCriteria.PostgresJsonCriteriaStep json() { return new PgSql.PgCriteria.PostgresJsonCriteriaStep() { @Override - public PgSql.PgCriteria contains(String field) { - return createCriteria("?", new ValueExpression(field)); + public PgSql.PgCriteria exists(QueryExpression expression) { + return createCriteria("?", expression); } @Override public PgSql.PgCriteria contains(Object value) { - return createCriteria("?", value); + return createCriteria("@>", value); } @Override public PgSql.PgCriteria containsAll(Iterable values) { - return createCriteria("?&", new ArrayExpression(values)); - } - - @Override - public PgSql.PgCriteria containsAll(Object... values) { - return createCriteria("?&", new ArrayExpression(Arrays.asList(values))); + return createCriteria("?&", ArrayExpression.expressionOrWrap(values)); } @Override public PgSql.PgCriteria containsAny(Iterable values) { - return createCriteria("?|", new ArrayExpression(values)); - } - - @Override - public PgSql.PgCriteria containsAny(Object... values) { - return createCriteria("?|", new ArrayExpression(Arrays.asList(values))); + return createCriteria("?|", ArrayExpression.expressionOrWrap(values)); } @Override @@ -143,7 +126,7 @@ public PgSql.PgCriteria jsonPath(String jsonPath) { } private record PostgresComparison(QueryExpression lhs, String operator, - QueryExpression rhs) implements QueryExpression { + QueryExpression rhs) implements PgSql.PostgresQueryExpression { @Override public QueryRenderContext contextualize(QueryRenderContext context) { @@ -161,7 +144,7 @@ public Expression render(QueryRenderContext context) { } - private record ValueExpression(Object value) implements QueryExpression { + record ValueExpression(Object value) implements QueryExpression { @Override public Expression render(QueryRenderContext context) { @@ -177,6 +160,15 @@ public ValuesExpression(Iterable values) { this.values = values; } + public static QueryExpression oneOrMany(Object[] values) { + + if (values.length == 1) { + return new ValueExpression(values[0]); + } + + return new ValuesExpression(Arrays.asList(values)); + } + @Override public Expression render(QueryRenderContext context) { @@ -214,22 +206,64 @@ private static List createArgumentExpressions(Iterable value record ArrayExpression(Iterable values) implements QueryExpression { + public static QueryExpression expressionOrWrap(Object[] values) { + return expressionOrWrap(Arrays.asList(values)); + } + + public static QueryExpression expressionOrWrap(Iterable values) { + + Iterator iterator = values.iterator(); + if (iterator.hasNext()) { + + Object next = iterator.next(); + if (!iterator.hasNext() && next instanceof QueryExpression queryExpression) { + return queryExpression; + } + } + + return new ArrayExpression(values); + + } + @Override public Expression render(QueryRenderContext context) { return BaseFunction.create("array", "[", "]", FunctionExpression.createArgumentExpressions(values(), context)); } } - record DefaultFunctions(CriteriaSource source) implements PgSql.Functions { + record JsonExpression(Map jsonObject, String type) implements QueryExpression { + + @Override + public Expression render(QueryRenderContext context) { + return new PostfixExpression(context.bind(jsonObject), Expressions.just("::" + type)); + } + } + + record DefaultOperators(QueryExpression source) implements PgSql.Operators { + + @Override + public PgSql.PostgresQueryExpression contains(QueryExpression expression) { + return new ArrayOperator(source, "@>", expression); + } + + @Override + public PgSql.PostgresQueryExpression overlaps(QueryExpression expression) { + return new ArrayOperator(source, "&&", expression); + } + + @Override + public PgSql.PostgresQueryExpression concatWith(QueryExpression expression) { + return new ArrayOperator(source, "||", expression); + } @Override public PgSql.PostgresQueryExpression index(int index) { - return new JsonIndexFunction(source, JsonIndexFunction.FIELD_OR_INDEX, index); + return new JsonIndexOperator(source, JsonIndexOperator.FIELD_OR_INDEX, index); } @Override public PgSql.PostgresQueryExpression field(String field) { - return new JsonIndexFunction(source, JsonIndexFunction.FIELD_OR_INDEX, field); + return new JsonIndexOperator(source, JsonIndexOperator.FIELD_OR_INDEX, field); } @Override @@ -239,7 +273,7 @@ public PgSql.PostgresQueryExpression distanceTo(Vector vector, } } - record DistanceFunction(CriteriaSource source, ScoringFunction scoringFunction, + record DistanceFunction(QueryExpression source, ScoringFunction scoringFunction, Vector vector) implements PgSql.PostgresQueryExpression { @Override @@ -269,21 +303,49 @@ private String getOperator() { } } - static class JsonIndexFunction implements PgSql.PostgresQueryExpression { + static class ArrayOperator implements PgSql.PostgresQueryExpression { + + private final QueryExpression lhs; + private final String operator; + private final QueryExpression rhs; + + public ArrayOperator(QueryExpression lhs, String operator, QueryExpression rhs) { + this.lhs = lhs; + this.operator = operator; + this.rhs = rhs; + } + + @Override + public PgSql.PostgresQueryExpression as(String type) { + return new AppendingPostgresExpression(this.nest(), "::" + type); + } + + @Override + public QueryRenderContext contextualize(QueryRenderContext context) { + return context; + } + + @Override + public Expression render(QueryRenderContext context) { + return OperatorExpression.create(lhs.render(context), operator, rhs.render(context)); + } + } + + static class JsonIndexOperator implements PgSql.PostgresQueryExpression { public static final String FIELD_OR_INDEX = "-"; public static final String PATH = "#"; - private final CriteriaSource source; + private final QueryExpression source; private final String baseOperator; private final boolean asString; private final Object keyOrIndex; - public JsonIndexFunction(CriteriaSource source, String baseOperator, Object keyOrIndex) { + public JsonIndexOperator(QueryExpression source, String baseOperator, Object keyOrIndex) { this(source, baseOperator, false, keyOrIndex); } - public JsonIndexFunction(CriteriaSource source, String baseOperator, boolean asString, Object keyOrIndex) { + public JsonIndexOperator(QueryExpression source, String baseOperator, boolean asString, Object keyOrIndex) { this.source = source; this.baseOperator = baseOperator; this.asString = asString; @@ -292,7 +354,7 @@ public JsonIndexFunction(CriteriaSource source, String baseOperator, boolean asS @Override public PgSql.PostgresQueryExpression asString() { - return new JsonIndexFunction(this.source, this.baseOperator, true, this.keyOrIndex); + return new JsonIndexOperator(this.source, this.baseOperator, true, this.keyOrIndex); } @Override diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/query/QueryExpression.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/query/QueryExpression.java index 08c5146ead..1a59798add 100644 --- a/spring-data-relational/src/main/java/org/springframework/data/relational/core/query/QueryExpression.java +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/query/QueryExpression.java @@ -41,6 +41,8 @@ default QueryExpression nest() { return new NestedQueryExpression(this); } + // TODO: get type? + // TODO: Used to determine the inner-most context. default QueryRenderContext contextualize(QueryRenderContext context) { return context; @@ -78,6 +80,7 @@ interface QueryRenderContext { // only available after contextualization Expression getColumnName(); + // TODO: we should have a bind method that performs JSON serialization. BindMarker bind(Object value); BindMarker bind(String name, Object value); diff --git a/spring-data-relational/src/test/java/org/springframework/data/relational/core/query/PgSqlUnitTests.java b/spring-data-relational/src/test/java/org/springframework/data/relational/core/query/PgSqlUnitTests.java index d46baba60f..d6e059e97d 100644 --- a/spring-data-relational/src/test/java/org/springframework/data/relational/core/query/PgSqlUnitTests.java +++ b/spring-data-relational/src/test/java/org/springframework/data/relational/core/query/PgSqlUnitTests.java @@ -17,6 +17,8 @@ import static org.assertj.core.api.Assertions.*; +import java.util.Map; + import org.junit.jupiter.api.Test; import org.springframework.data.domain.Vector; @@ -47,7 +49,7 @@ void distanceLessThan() { // distanceOf("embedding").l2() Criteria embedding = PgSql - .where("embedding", it -> it.distanceTo(Vector.of(1, 2, 3), PgSql.VectorSearchFunctions.Distances::cosine)) + .where("embedding", it -> it.distanceTo(Vector.of(1, 2, 3), PgSql.VectorSearchOperators.Distances::cosine)) .lessThan(0.8); String sql = toSql(embedding); @@ -66,9 +68,8 @@ void fieldIsActiveIsTrue() { } @Test - void containsArray() { + void jsonContainsAll() { - // SqlIdentifier to refer to columns? Criteria arrayContains = PgSql.where("tags").json().containsAll("electronics", "gaming"); String sql = toSql(arrayContains); @@ -76,11 +77,43 @@ void containsArray() { assertThat(sql).contains("with_embedding.tags ?& array[:p0, :p1]"); } + @Test + void toJsonEquals() { + + QueryExpression json = PgSql.json().jsonOf(Map.of("foo", "bar")); + Criteria arrayContains = PgSql.where(json, it -> it.json().index(1)).is("bar"); + + String sql = toSql(arrayContains); + + assertThat(sql).contains(":p0::json -> :p1 = :p2"); + } + + @Test + void arrayOverlapsColumn() { + + Criteria arrayContains = PgSql.where("tags").arrays().overlaps("country"); + + String sql = toSql(arrayContains); + + assertThat(sql).contains("with_embedding.tags && with_embedding.country"); + } + + @Test + void arrayConcatColumnsAndContains() { + + Criteria arrayContains = PgSql.where("tags", it -> it.array().concatWith("other_tags")).arrays().contains("1", "2", + "3"); + + String sql = toSql(arrayContains); + + assertThat(sql).contains("with_embedding.tags || with_embedding.other_tags @> array[:p0, :p1, :p2]"); + } + @Test void shouldRenderSourceFunction() { // SqlIdentifier to refer to columns? - Criteria arrayContains = PgSql.where(PgSql.function("array_ndims", CriteriaSource.ofDotPath("someArray"))) + Criteria arrayContains = PgSql.where(PgSql.function("array_ndims", CriteriaSource.ofColumn("someArray"))) .greaterThan(2); String sql = toSql(arrayContains); @@ -91,7 +124,8 @@ void shouldRenderSourceFunction() { @Test void arrayContains() { - Criteria someArrayContains = PgSql.where("someArray").contains(PgSql.arrays().arrayOf("electronics", "gaming")); + Criteria someArrayContains = PgSql.where("someArray").arrays() + .contains(PgSql.arrays().arrayOf("electronics", "gaming")); String sql = toSql(someArrayContains); From 03e15afd50f81902e5a89f583a29beafccf82ada Mon Sep 17 00:00:00 2001 From: Mark Paluch Date: Wed, 25 Jun 2025 11:27:37 +0200 Subject: [PATCH 4/8] Refinements. Introduce type context, apply type conversions for bindings. Simplify criteria sources. --- .../data/relational/core/query/Bindings.java | 6 +- .../data/relational/core/query/Criteria.java | 6 +- .../core/query/CriteriaContext.java | 35 --- .../core/query/CriteriaSources.java | 39 +++- .../core/query/NestedQueryExpression.java | 10 +- .../data/relational/core/query/PgSql.java | 20 +- .../data/relational/core/query/PgSqlImpl.java | 117 +++++++--- .../core/query/QueryExpression.java | 221 +++++++++++++++--- ...text.java => SimpleEvaluationContext.java} | 119 ++++++---- .../core/query/SimpleTypeContext.java | 41 ++++ .../relational/core/query/PgSqlUnitTests.java | 14 +- .../core/query/SimpleBindings.java} | 22 +- 12 files changed, 460 insertions(+), 190 deletions(-) delete mode 100644 spring-data-relational/src/main/java/org/springframework/data/relational/core/query/CriteriaContext.java rename spring-data-relational/src/main/java/org/springframework/data/relational/core/query/{SimpleQueryRenderContext.java => SimpleEvaluationContext.java} (70%) create mode 100644 spring-data-relational/src/main/java/org/springframework/data/relational/core/query/SimpleTypeContext.java rename spring-data-relational/src/{main/java/org/springframework/data/relational/core/query/CriteriaSource.java => test/java/org/springframework/data/relational/core/query/SimpleBindings.java} (56%) diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/query/Bindings.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/query/Bindings.java index 88ed1305b3..81d0759fd1 100644 --- a/spring-data-relational/src/main/java/org/springframework/data/relational/core/query/Bindings.java +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/query/Bindings.java @@ -15,11 +15,15 @@ */ package org.springframework.data.relational.core.query; +import org.springframework.data.relational.core.sql.BindMarker; + /** * @author Mark Paluch */ public interface Bindings { - String bind(Object values); + void bind(BindMarker bindMarker, Object value); + + void bind(BindMarker bindMarker, Object value, QueryExpression.ExpressionTypeContext typeContext); } 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 178c25f723..51c3cca018 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 @@ -169,7 +169,7 @@ public static CriteriaStep where(String column) { Assert.hasText(column, "Column name must not be null or empty"); - return new DefaultCriteriaStep(CriteriaSource.ofColumn(column), SqlIdentifier.unquoted(column)); + return new DefaultCriteriaStep(QueryExpression.column(column), SqlIdentifier.unquoted(column)); } /** @@ -183,7 +183,7 @@ public CriteriaStep and(String column) { Assert.hasText(column, "Column name must not be null or empty"); SqlIdentifier identifier = SqlIdentifier.unquoted(column); - CriteriaSource lhs = CriteriaSource.ofColumn(column); + QueryExpression lhs = QueryExpression.column(column); return new DefaultCriteriaStep(lhs, identifier) { @Override protected Criteria createCriteria(Comparator comparator, @Nullable Object value) { @@ -250,7 +250,7 @@ public CriteriaStep or(String column) { Assert.hasText(column, "Column name must not be null or empty"); SqlIdentifier identifier = SqlIdentifier.unquoted(column); - CriteriaSource lhs = CriteriaSource.ofColumn(column); + QueryExpression lhs = QueryExpression.column(column); return new DefaultCriteriaStep(lhs, identifier) { @Override protected Criteria createCriteria(Comparator comparator, @Nullable Object value) { diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/query/CriteriaContext.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/query/CriteriaContext.java deleted file mode 100644 index c1407ff36c..0000000000 --- a/spring-data-relational/src/main/java/org/springframework/data/relational/core/query/CriteriaContext.java +++ /dev/null @@ -1,35 +0,0 @@ -/* - * Copyright 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 org.springframework.data.relational.core.conversion.RelationalConverter; - -/** - * @author Mark Paluch - */ -public interface CriteriaContext { - - Object getSource(); - - RelationalConverter getConverter(); - - Bindings getBindings(); - - default String bind(Object value) { - return getBindings().bind(value); - } - -} diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/query/CriteriaSources.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/query/CriteriaSources.java index dd900c14ee..2ba3c2ecf8 100644 --- a/spring-data-relational/src/main/java/org/springframework/data/relational/core/query/CriteriaSources.java +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/query/CriteriaSources.java @@ -19,32 +19,51 @@ import org.springframework.data.relational.core.sql.SqlIdentifier; /** + * Utility class providing implementations of {@link CriteriaSource} for common sources such as columns and SQL + * identifiers. + * * @author Mark Paluch + * @since 4.0 */ class CriteriaSources { - public static final record Column(String name) implements CriteriaSource { + + /** + * A column name (or property path) reference. + * + * @param name + */ + public record Column(String name) implements QueryExpression { @Override - public QueryRenderContext contextualize(QueryRenderContext context) { - return context.withProperty(name); + public ExpressionTypeContext getType(EvaluationContext context) { + return context.getColumn(name); } @Override - public Expression render(QueryRenderContext context) { - return context.getColumnName(name); + public Expression evaluate(EvaluationContext context) { + return context.getColumn(name).toExpression(); } + } - public static final record SqlIdentifierSource(SqlIdentifier identifier) implements CriteriaSource { + /** + * A column or alias name. + * + * @param identifier + */ + public record SqlIdentifierSource(SqlIdentifier identifier) implements QueryExpression { @Override - public QueryRenderContext contextualize(QueryRenderContext context) { - return context.withProperty(identifier); + public ExpressionTypeContext getType(EvaluationContext context) { + return context.getColumn(identifier); } + @Override - public Expression render(QueryRenderContext context) { - return context.getColumnName(identifier); + public Expression evaluate(EvaluationContext context) { + return context.getColumn(identifier).toExpression(); } + } + } diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/query/NestedQueryExpression.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/query/NestedQueryExpression.java index d828e92e6f..0640b9f1b2 100644 --- a/spring-data-relational/src/main/java/org/springframework/data/relational/core/query/NestedQueryExpression.java +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/query/NestedQueryExpression.java @@ -19,6 +19,8 @@ import org.springframework.data.relational.core.sql.Expressions; /** + * Query expression that nests another {@link QueryExpression}. This is used to ensure that the nested expression + * * @author Mark Paluch */ class NestedQueryExpression implements QueryExpression { @@ -35,13 +37,13 @@ public QueryExpression nest() { } @Override - public QueryRenderContext contextualize(QueryRenderContext context) { - return expression.contextualize(context); + public ExpressionTypeContext getType(EvaluationContext context) { + return expression.getType(context); } @Override - public Expression render(QueryRenderContext context) { - return Expressions.nest(expression.render(context)); + public Expression evaluate(EvaluationContext context) { + return Expressions.nest(expression.evaluate(context)); } } diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/query/PgSql.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/query/PgSql.java index f205d41b53..ed6e3f0a8f 100644 --- a/spring-data-relational/src/main/java/org/springframework/data/relational/core/query/PgSql.java +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/query/PgSql.java @@ -165,7 +165,7 @@ public PgCriteria.PostgresCriteriaStep and(String column) { Assert.hasText(column, "Column name must not be null or empty"); - return and(CriteriaSource.ofColumn(column)); + return and(QueryExpression.column(column)); } @Override @@ -181,7 +181,7 @@ public PgCriteria.PostgresCriteriaStep or(String column) { Assert.hasText(column, "Column name must not be null or empty"); - return or(CriteriaSource.ofColumn(column)); + return or(QueryExpression.column(column)); } @Override @@ -251,7 +251,7 @@ public interface PostgresArrayCriteriaStep { * @property */ default PgCriteria contains(String column) { - return contains(CriteriaSource.ofColumn(column)); + return contains(QueryExpression.column(column)); } /** @@ -280,7 +280,7 @@ default PgCriteria contains(Object... values) { * @return */ default PgCriteria overlaps(String column) { - return overlaps(CriteriaSource.ofColumn(column)); + return overlaps(QueryExpression.column(column)); } /** @@ -301,7 +301,7 @@ default PgCriteria overlaps(Object... values) { public interface PostgresJsonCriteriaStep { default PgCriteria exists(String column) { - return exists(CriteriaSource.ofColumn(column)); + return exists(QueryExpression.column(column)); } /** @@ -323,7 +323,7 @@ default PgCriteria exists(Object value) { } default PgCriteria contains(String field) { - return contains(CriteriaSource.ofColumn(field)); + return contains(QueryExpression.column(field)); } PgCriteria contains(Object value); @@ -518,7 +518,7 @@ public interface ArrayOperators { * @property */ default PostgresQueryExpression contains(String column) { - return contains(CriteriaSource.ofColumn(column)); + return contains(QueryExpression.column(column)); } /** @@ -547,7 +547,7 @@ default PostgresQueryExpression contains(Object... values) { * @return */ default PostgresQueryExpression overlaps(String column) { - return overlaps(CriteriaSource.ofColumn(column)); + return overlaps(QueryExpression.column(column)); } /** @@ -577,7 +577,7 @@ default PostgresQueryExpression overlaps(Object... values) { * @return */ default PostgresQueryExpression concatWith(String column) { - return concatWith(CriteriaSource.ofColumn(column)); + return concatWith(QueryExpression.column(column)); } /** @@ -604,7 +604,7 @@ public interface PostgresQueryExpression extends QueryExpression { * @return */ default PostgresQueryExpression as(String type) { - return new PgSqlImpl.AppendingPostgresExpression(this, "::" + type); + return PgSqlImpl.PgCastExpression.create(this, type); } /** diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/query/PgSqlImpl.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/query/PgSqlImpl.java index 085b4d4315..e24d9ae4d5 100644 --- a/spring-data-relational/src/main/java/org/springframework/data/relational/core/query/PgSqlImpl.java +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/query/PgSqlImpl.java @@ -19,6 +19,7 @@ import java.util.Arrays; import java.util.Iterator; import java.util.List; +import java.util.Locale; import java.util.Map; import java.util.function.Function; @@ -48,7 +49,7 @@ static class DefaultPostgresCriteriaStep extends Criteria.DefaultCriteriaStep implements PgSql.PgCriteria.PostgresCriteriaStep { protected DefaultPostgresCriteriaStep(String propertyName) { - this(CriteriaSource.ofColumn(propertyName)); + this(QueryExpression.column(propertyName)); } protected DefaultPostgresCriteriaStep(QueryExpression source) { @@ -129,15 +130,15 @@ private record PostgresComparison(QueryExpression lhs, String operator, QueryExpression rhs) implements PgSql.PostgresQueryExpression { @Override - public QueryRenderContext contextualize(QueryRenderContext context) { - return this.rhs.contextualize(this.lhs.contextualize(context)); + public ExpressionTypeContext getType(EvaluationContext context) { + return ExpressionTypeContext.bool(); } @Override - public Expression render(QueryRenderContext context) { + public Expression evaluate(EvaluationContext context) { - Expression lhs = this.lhs.render(context); - Expression rhs = this.rhs.render(context); + Expression lhs = this.lhs.evaluate(context.withType(this.rhs)); + Expression rhs = this.rhs.evaluate(context.withType(this.lhs)); return Comparison.create(lhs, operator, rhs); } @@ -147,7 +148,7 @@ public Expression render(QueryRenderContext context) { record ValueExpression(Object value) implements QueryExpression { @Override - public Expression render(QueryRenderContext context) { + public Expression evaluate(EvaluationContext context) { return context.bind(value); } } @@ -170,7 +171,7 @@ public static QueryExpression oneOrMany(Object[] values) { } @Override - public Expression render(QueryRenderContext context) { + public Expression evaluate(EvaluationContext context) { List bindMarkers = new ArrayList<>(); for (Object value : this.values) { @@ -184,18 +185,18 @@ public Expression render(QueryRenderContext context) { record FunctionExpression(String function, Iterable values) implements PgSql.PostgresQueryExpression { @Override - public Expression render(QueryRenderContext context) { + public Expression evaluate(EvaluationContext context) { return SimpleFunction.create(function, createArgumentExpressions(values(), context)); } - private static List createArgumentExpressions(Iterable values, QueryRenderContext context) { + private static List createArgumentExpressions(Iterable values, EvaluationContext context) { List arguments = new ArrayList<>(); for (Object value : values) { if (value == null) { arguments.add(Expressions.just("NULL")); } else if (value instanceof QueryExpression qe) { - arguments.add(qe.render(context)); + arguments.add(qe.evaluate(context)); } else { arguments.add(context.bind(value)); } @@ -222,11 +223,15 @@ public static QueryExpression expressionOrWrap(Iterable values) { } return new ArrayExpression(values); + } + @Override + public ExpressionTypeContext getType(EvaluationContext context) { + return ExpressionTypeContext.object().asCollection(); } @Override - public Expression render(QueryRenderContext context) { + public Expression evaluate(EvaluationContext context) { return BaseFunction.create("array", "[", "]", FunctionExpression.createArgumentExpressions(values(), context)); } } @@ -234,7 +239,7 @@ public Expression render(QueryRenderContext context) { record JsonExpression(Map jsonObject, String type) implements QueryExpression { @Override - public Expression render(QueryRenderContext context) { + public Expression evaluate(EvaluationContext context) { return new PostfixExpression(context.bind(jsonObject), Expressions.just("::" + type)); } } @@ -243,17 +248,19 @@ record DefaultOperators(QueryExpression source) implements PgSql.Operators { @Override public PgSql.PostgresQueryExpression contains(QueryExpression expression) { - return new ArrayOperator(source, "@>", expression); + return new ArrayOperator(source, "@>", expression, + ArrayOperator.just(QueryExpression.ExpressionTypeContext.bool())); } @Override public PgSql.PostgresQueryExpression overlaps(QueryExpression expression) { - return new ArrayOperator(source, "&&", expression); + return new ArrayOperator(source, "&&", expression, + ArrayOperator.just(QueryExpression.ExpressionTypeContext.bool())); } @Override public PgSql.PostgresQueryExpression concatWith(QueryExpression expression) { - return new ArrayOperator(source, "||", expression); + return new ArrayOperator(source, "||", expression, QueryExpression::getType); } @Override @@ -277,16 +284,16 @@ record DistanceFunction(QueryExpression source, ScoringFunction scoringFunction, Vector vector) implements PgSql.PostgresQueryExpression { @Override - public QueryRenderContext contextualize(QueryRenderContext context) { - return source.contextualize(context); + public ExpressionTypeContext getType(EvaluationContext context) { + return ExpressionTypeContext.of(Number.class); } @Override - public Expression render(QueryRenderContext context) { + public Expression evaluate(EvaluationContext context) { String operator = getOperator(); - return OperatorExpression.create(source.render(context), operator, context.bind(vector)); + return OperatorExpression.create(source.evaluate(context), operator, context.bind(vector)); } private String getOperator() { @@ -308,26 +315,37 @@ static class ArrayOperator implements PgSql.PostgresQueryExpression { private final QueryExpression lhs; private final String operator; private final QueryExpression rhs; + private final TypeFunction typeFunction; - public ArrayOperator(QueryExpression lhs, String operator, QueryExpression rhs) { + public ArrayOperator(QueryExpression lhs, String operator, QueryExpression rhs, TypeFunction typeFunction) { this.lhs = lhs; this.operator = operator; this.rhs = rhs; + this.typeFunction = typeFunction; + } + + static TypeFunction just(ExpressionTypeContext type) { + return (ex, context) -> type; } @Override public PgSql.PostgresQueryExpression as(String type) { - return new AppendingPostgresExpression(this.nest(), "::" + type); + return PgCastExpression.create(this.nest(), type); } @Override - public QueryRenderContext contextualize(QueryRenderContext context) { - return context; + public ExpressionTypeContext getType(EvaluationContext context) { + + ExpressionTypeContext l = typeFunction.getType(lhs, context); + ExpressionTypeContext r = typeFunction.getType(rhs, context); + + return l.getAssignableType(r); } @Override - public Expression render(QueryRenderContext context) { - return OperatorExpression.create(lhs.render(context), operator, rhs.render(context)); + public Expression evaluate(EvaluationContext context) { + return OperatorExpression.create(lhs.evaluate(context.withType(rhs)), operator, + rhs.evaluate(context.withType(lhs))); } } @@ -369,31 +387,49 @@ public PgSql.PostgresQueryExpression asJsonb() { @Override public PgSql.PostgresQueryExpression as(String type) { - return new AppendingPostgresExpression(this.nest(), "::" + type); + return new PgCastExpression(this.nest(), "::" + type, (it, ctx) -> TypeCast.getType(type)); } @Override - public QueryRenderContext contextualize(QueryRenderContext context) { - return source.contextualize(context); + public ExpressionTypeContext getType(EvaluationContext context) { + return asString ? ExpressionTypeContext.of(String.class) : ExpressionTypeContext.object(); } @Override - public Expression render(QueryRenderContext context) { - return OperatorExpression.create(source.render(context), baseOperator + (asString ? ">>" : ">"), + public Expression evaluate(EvaluationContext context) { + return OperatorExpression.create(source.evaluate(context), baseOperator + (asString ? ">>" : ">"), context.bind(keyOrIndex)); } } - record AppendingPostgresExpression(QueryExpression source, String appendix) implements PgSql.PostgresQueryExpression { + static class TypeCast { + + public static QueryExpression.ExpressionTypeContext getType(String type) { + + return switch (type.toLowerCase(Locale.ROOT)) { + case "varchar", "text" -> QueryExpression.ExpressionTypeContext.string(); + case "boolean" -> QueryExpression.ExpressionTypeContext.bool(); + default -> QueryExpression.ExpressionTypeContext.object(); + }; + + } + } + + record PgCastExpression(QueryExpression source, String typeCast, + TypeFunction typeFunction) implements PgSql.PostgresQueryExpression { + + static PgCastExpression create(QueryExpression source, String type) { + return new PgCastExpression(source, "::" + type, (it, context) -> TypeCast.getType(type)); + } @Override - public QueryRenderContext contextualize(QueryRenderContext context) { - return source.contextualize(context); + public ExpressionTypeContext getType(EvaluationContext context) { + return typeFunction.getType(source, context); } @Override - public Expression render(QueryRenderContext context) { - return new PostfixExpression(source.render(context), Expressions.just(appendix)); + public Expression evaluate(EvaluationContext context) { + return new PostfixExpression(source.evaluate(context), Expressions.just(typeCast)); } } @@ -401,8 +437,13 @@ record ArrayIndexPostgresExpression(QueryExpression source, Object keyOrIndex) implements PgSql.PostgresQueryExpression { @Override - public Expression render(QueryRenderContext context) { - return new PostfixExpression(source.render(context), new ArrayIndexExpression(context.bind(keyOrIndex))); + public ExpressionTypeContext getType(EvaluationContext context) { + return source.getType(context).getActualType(); + } + + @Override + public Expression evaluate(EvaluationContext context) { + return new PostfixExpression(source.evaluate(context), new ArrayIndexExpression(context.bind(keyOrIndex))); } } diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/query/QueryExpression.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/query/QueryExpression.java index 1a59798add..7645188214 100644 --- a/spring-data-relational/src/main/java/org/springframework/data/relational/core/query/QueryExpression.java +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/query/QueryExpression.java @@ -15,9 +15,14 @@ */ package org.springframework.data.relational.core.query; +import org.springframework.data.relational.core.mapping.RelationalPersistentProperty; import org.springframework.data.relational.core.sql.BindMarker; import org.springframework.data.relational.core.sql.Expression; import org.springframework.data.relational.core.sql.SqlIdentifier; +import org.springframework.data.util.TypeInformation; +import org.springframework.lang.CheckReturnValue; +import org.springframework.lang.Nullable; +import org.springframework.util.Assert; /** * An expression that can be used in a query, such as a column, condition or a function. @@ -27,10 +32,31 @@ * and placeholders). * * @author Mark Paluch - * @since 4.0s + * @since 4.0 */ +@FunctionalInterface public interface QueryExpression { + /** + * Creates a {@link QueryExpression} for a column name (or property path). + * + * @param name + * @return + */ + static QueryExpression column(String name) { + return new CriteriaSources.Column(name); + } + + /** + * Creates a {@link QueryExpression} for a SQL identifier (column or alias name). + * + * @param identifier + * @return + */ + static QueryExpression of(SqlIdentifier identifier) { + return new CriteriaSources.SqlIdentifierSource(identifier); + } + /** * Nests this expression by wrapping it in parentheses {@code (…)}. This is useful for grouping expressions in a query * or to clarify an operator scope to avoid ambiguity in complex expressions. @@ -41,52 +67,183 @@ default QueryExpression nest() { return new NestedQueryExpression(this); } - // TODO: get type? - - // TODO: Used to determine the inner-most context. - default QueryRenderContext contextualize(QueryRenderContext context) { - return context; + /** + * Determine the data type used by this expression. Typing is used to determine the target type for conversion and + * binding. + * + * @param context context providing information to evaluate the type of this expression. + * @return expression value type. + */ + default ExpressionTypeContext getType(EvaluationContext context) { + return ExpressionTypeContext.object(); } /** - * Renders this expression into a SQL {@link Expression} that can be used in a query. - * - * @implNote an expression such as a comparison requires a contextualized {@link QueryRenderContext} that considers - * the left-hand side and the right-hand side of the expression. It is necessary to determine the target - * type when comparing a column to a given value. But what if we compare two columns? Something like: - * - *
-	 *           CREATE TABLE person (id int, name varchar(100), country varchar(2), no_visa_required varchar[2]);
-	 *           SELECT * FROM person WHERE no_visa_required && (overlap, any elements in common) ARRAY('DE, country, '??');
-	 *           SELECT * FROM person WHERE (no_visa_required || country) && (overlap) ARRAY('DE, '??');
-	 *           
+ * Evaluates this query expression into a SQL {@link Expression} that can be used in a query. + *

+ * Evaluation renders an expression from its given input such as a comparison or operator to represent a specific SQL + * operation. Implementations can bind values and obtain bind markers during evaluation. * - * @param context - * @return + * @param context the context to evaluate this expression in. The context provides access to columns and allows + * binding values. + * @return the evaluated SQL expression to be used for SQL rendering. */ - Expression render(QueryRenderContext context); + Expression evaluate(EvaluationContext context); - // TODO better design to support nested expressions - interface QueryRenderContext { + /** + * Interface to bind values to (i.e. during evaluation of a {@link QueryExpression}). + */ + interface BindableContext { - QueryRenderContext withProperty(String dotPath); + // TODO: we should have a bind method that performs JSON serialization. + BindMarker bind(Object value); - QueryRenderContext withProperty(SqlIdentifier identifier); + BindMarker bind(String name, Object value); + } - Expression getColumnName(SqlIdentifier identifier); + /** + * A mapped column that is referenced by a {@code QueryExpression}. This is typically a column in a table, join, or + * subselect. + */ + interface MappedColumn extends ExpressionTypeContext { - Expression getColumnName(String dotPath); + /** + * @return the {@link Expression} representing this column. + */ + Expression toExpression(); - // only available after contextualization - Expression getColumnName(); + } - // TODO: we should have a bind method that performs JSON serialization. - BindMarker bind(Object value); + /** + * {@link QueryExpression}s are evaluated within this context and can obtain details about columns and bind values to + * it. + */ + interface EvaluationContext extends BindableContext { + + /** + * Retrieve a mapped column by its identifier along with typing information. If the identifier does not map to a + * property path, returns the plain column name without detailed type information. + * + * @param identifier name of the column. + * @return + */ + MappedColumn getColumn(SqlIdentifier identifier); + + /** + * Retrieve a mapped column by its identifier along with typing information. If the identifier does not map to a + * property path, returns the plain column name without detailed type information. + * + * @param column column name. Can contain a property path. + * @return + */ + MappedColumn getColumn(String column); + + /** + * Creates a new {@code EvaluationContext} typed to the {@link ExpressionTypeContext}. + * + * @param type the type to use for this context. This is used to determine the target type for conversion and + * binding. + * @return a new typed {@code EvaluationContext}. + */ + @CheckReturnValue + EvaluationContext withType(ExpressionTypeContext type); + + /** + * Creates a new {@code EvaluationContext} typed to the {@link QueryExpression}. + * + * @param expression the expression to derive its type from to use for this context. This is used to determine the + * target type for conversion and binding. + * @return a new typed {@code EvaluationContext}. + */ + default EvaluationContext withType(QueryExpression expression) { + + Assert.notNull(expression, "Expression must not be null"); + return withType(expression.getType(this)); + } + } - BindMarker bind(String name, Object value); + /** + * Interface providing details about the value type used by an expression. + *

+ * Provides factory methods to create {@link ExpressionTypeContext} instances and methods to introspect the type. + * TODO: Better name. + */ + interface ExpressionTypeContext { + + static ExpressionTypeContext object() { + return SimpleTypeContext.OBJECT; + } + + static ExpressionTypeContext bool() { + return SimpleTypeContext.BOOL; + } + + static ExpressionTypeContext of(Class type) { + return new SimpleTypeContext(TypeInformation.of(type)); + } + + static ExpressionTypeContext string() { + return SimpleTypeContext.STRING; + } + + TypeInformation getTargetType(); + + @Nullable + RelationalPersistentProperty getProperty(); + + /** + * Nest the type as collection. + * + * @return + */ + default ExpressionTypeContext asCollection() { + return new SimpleTypeContext(TypeInformation.of(getTargetType().toResolvableType().asCollection())); + } + + /** + * Returns the collection component type (collection or map). + * + * @return + */ + default ExpressionTypeContext getActualType() { + return new SimpleTypeContext(getTargetType().getRequiredActualType()); + } + + /** + * Returns the assignable type that is shared by this and the {@code other} type. + * + * @param other the other type to compare with. + * @return the assignable type that is shared by this and the {@code other} type. + */ + default ExpressionTypeContext getAssignableType(ExpressionTypeContext other) { + + // narrow check: cross-assignability + if (getTargetType().isAssignableFrom(other.getTargetType()) + && other.getTargetType().isAssignableFrom(getTargetType())) { + return this; + } + + // other might be more specific + if (getTargetType().isAssignableFrom(other.getTargetType())) { + return other; + } + + // this might be more specific + if (other.getTargetType().isAssignableFrom(getTargetType())) { + return this; + } + + return ExpressionTypeContext.object(); + } + } - Object writeValue(Object value); + /** + * Interface to determine its expression value type based on the expression and the render context. + */ + @FunctionalInterface + interface TypeFunction { + ExpressionTypeContext getType(QueryExpression expression, EvaluationContext context); } } diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/query/SimpleQueryRenderContext.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/query/SimpleEvaluationContext.java similarity index 70% rename from spring-data-relational/src/main/java/org/springframework/data/relational/core/query/SimpleQueryRenderContext.java rename to spring-data-relational/src/main/java/org/springframework/data/relational/core/query/SimpleEvaluationContext.java index dd7391e8cb..dcf3ac1350 100644 --- a/spring-data-relational/src/main/java/org/springframework/data/relational/core/query/SimpleQueryRenderContext.java +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/query/SimpleEvaluationContext.java @@ -28,9 +28,11 @@ import org.springframework.data.relational.core.mapping.RelationalPersistentEntity; import org.springframework.data.relational.core.mapping.RelationalPersistentProperty; import org.springframework.data.relational.core.sql.BindMarker; +import org.springframework.data.relational.core.sql.Column; import org.springframework.data.relational.core.sql.Expression; import org.springframework.data.relational.core.sql.SqlIdentifier; import org.springframework.data.relational.core.sql.Table; +import org.springframework.data.relational.core.sql.TableLike; import org.springframework.data.util.TypeInformation; import org.springframework.lang.Nullable; import org.springframework.util.Assert; @@ -39,100 +41,117 @@ /** * @author Mark Paluch */ -public class SimpleQueryRenderContext implements QueryExpression.QueryRenderContext { +public class SimpleEvaluationContext implements QueryExpression.EvaluationContext { private final Table table; private final BindMarkers bindMarkers; + private final Bindings bindings; private final RelationalConverter converter; private final RelationalPersistentEntity entity; - private final @Nullable Field field; + private final QueryExpression.ExpressionTypeContext type; - public SimpleQueryRenderContext(Table table, BindMarkersFactory bindMarkersFactory, RelationalConverter converter, + public SimpleEvaluationContext(Table table, BindMarkersFactory bindMarkersFactory, Bindings bindings, + RelationalConverter converter, RelationalPersistentEntity entity) { this.table = table; this.bindMarkers = bindMarkersFactory.create(); + this.bindings = bindings; this.converter = converter; this.entity = entity; - this.field = null; + this.type = QueryExpression.ExpressionTypeContext.object(); } - public SimpleQueryRenderContext(Table table, BindMarkers bindMarkers, RelationalConverter converter, - RelationalPersistentEntity entity, @Nullable Field field) { + public SimpleEvaluationContext(Table table, BindMarkers bindMarkers, Bindings bindings, RelationalConverter converter, + RelationalPersistentEntity entity, QueryExpression.ExpressionTypeContext type) { + + Assert.notNull(table, "Table must not be null"); + Assert.notNull(type, "Type must not be null"); + this.table = table; this.bindMarkers = bindMarkers; + this.bindings = bindings; this.converter = converter; this.entity = entity; - this.field = field; + this.type = type; } - Field createPropertyField(SqlIdentifier key) { - return entity == null ? new Field(key) : new MetadataBackedField(key, entity, converter.getMappingContext()); + public Bindings getBindings() { + return bindings; } - Field createPropertyField(SqlIdentifier key, - MappingContext, ? extends RelationalPersistentProperty> mappingContext) { - return entity == null ? new Field(key) : new MetadataBackedField(key, entity, mappingContext); + private Field createPropertyField(SqlIdentifier key) { + return new MetadataBackedField(key, entity, converter.getMappingContext()); } @Override - public QueryExpression.QueryRenderContext withProperty(String dotPath) { - // todo: check RelationalMappingContext.isForceQuote() and use SqlIdentifier.quoted() if necessary - return new SimpleQueryRenderContext(table, bindMarkers, converter, entity, - createPropertyField(SqlIdentifier.unquoted(dotPath))); - } + public QueryExpression.MappedColumn getColumn(SqlIdentifier identifier) { - @Override - public QueryExpression.QueryRenderContext withProperty(SqlIdentifier identifier) { - return new SimpleQueryRenderContext(table, bindMarkers, converter, entity, createPropertyField(identifier)); + Field propertyField = createPropertyField(identifier); + return new DefaultMappedColumn(table, table.column(propertyField.getMappedColumnName()), propertyField); } + // TODO: Some tables can come from a JOIN, see JDBC @Override - public Expression getColumnName(SqlIdentifier identifier) { + public QueryExpression.MappedColumn getColumn(String column) { - if (field == null) { - return withProperty(identifier).getColumnName(); - } - return table.column(createPropertyField(identifier).getMappedColumnName()); + Field propertyField = createPropertyField(SqlIdentifier.unquoted(column)); + return new DefaultMappedColumn(table, table.column(propertyField.getMappedColumnName()), propertyField); } @Override - public Expression getColumnName(String dotPath) { - - if (field == null) { - return withProperty(dotPath).getColumnName(); - } - - return table.column(createPropertyField(SqlIdentifier.unquoted(dotPath)).getMappedColumnName()); + public QueryExpression.EvaluationContext withType(QueryExpression.ExpressionTypeContext type) { + return new SimpleEvaluationContext(table, bindMarkers, bindings, converter, entity, type); } @Override public BindMarker bind(Object value) { - // TODO - return bindMarkers.next(); + BindMarker bindMarker = bindMarkers.next(); + + bindings.bind(bindMarker, converter.writeValue(value, type.getTargetType())); + + return bindMarker; } @Override public BindMarker bind(String name, Object value) { - // TODO - return bindMarkers.next(name); - } + BindMarker bindMarker = bindMarkers.next(name); - @Override - public Object writeValue(Object value) { - return converter.writeValue(value, field == null ? TypeInformation.OBJECT : field.getTypeHint()); + bindings.bind(bindMarker, converter.writeValue(value, type.getTargetType())); + + return bindMarker; } - @Override - public Expression getColumnName() { + class DefaultMappedColumn implements QueryExpression.MappedColumn { - if (field == null) { - throw new IllegalStateException("RenderContext not associated with a field. Call withProperty(…) first."); + private final TableLike table; + private final Column column; + private final Field field; + + DefaultMappedColumn(TableLike table, Column column, Field field) { + this.table = table; + this.column = column; + this.field = field; + } + + @Override + public Expression toExpression() { + return column; + } + + @Override + public TypeInformation getTargetType() { + return field.getTypeHint(); + } + + @Nullable + @Override + public RelationalPersistentProperty getProperty() { + return field.getProperty(); } - return table.column(field.getMappedColumnName()); } /** @@ -165,6 +184,10 @@ public SqlIdentifier getMappedColumnName() { public TypeInformation getTypeHint() { return TypeInformation.OBJECT; } + + public @Nullable RelationalPersistentProperty getProperty() { + return null; + } } /** @@ -278,5 +301,11 @@ public TypeInformation getTypeHint() { return this.property.getTypeInformation(); } + + @Nullable + @Override + public RelationalPersistentProperty getProperty() { + return this.property; + } } } diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/query/SimpleTypeContext.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/query/SimpleTypeContext.java new file mode 100644 index 0000000000..9633572d7b --- /dev/null +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/query/SimpleTypeContext.java @@ -0,0 +1,41 @@ +/* + * Copyright 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 org.springframework.data.relational.core.mapping.RelationalPersistentProperty; +import org.springframework.data.util.TypeInformation; +import org.springframework.lang.Nullable; + +/** + * @author Mark Paluch + */ +record SimpleTypeContext(TypeInformation type) implements QueryExpression.ExpressionTypeContext { + + public static SimpleTypeContext OBJECT = new SimpleTypeContext(TypeInformation.OBJECT); + public static SimpleTypeContext BOOL = new SimpleTypeContext(TypeInformation.of(Boolean.TYPE)); + public static SimpleTypeContext STRING = new SimpleTypeContext(TypeInformation.of(String.class)); + + @Override + public TypeInformation getTargetType() { + return type; + } + + @Nullable + @Override + public RelationalPersistentProperty getProperty() { + return null; + } +} diff --git a/spring-data-relational/src/test/java/org/springframework/data/relational/core/query/PgSqlUnitTests.java b/spring-data-relational/src/test/java/org/springframework/data/relational/core/query/PgSqlUnitTests.java index d6e059e97d..be49a026ba 100644 --- a/spring-data-relational/src/test/java/org/springframework/data/relational/core/query/PgSqlUnitTests.java +++ b/spring-data-relational/src/test/java/org/springframework/data/relational/core/query/PgSqlUnitTests.java @@ -17,6 +17,7 @@ import static org.assertj.core.api.Assertions.*; +import java.math.BigDecimal; import java.util.Map; import org.junit.jupiter.api.Test; @@ -41,7 +42,9 @@ class PgSqlUnitTests { RelationalMappingContext context = new RelationalMappingContext(); MappingRelationalConverter converter = new MappingRelationalConverter(context); Table table = Table.create("with_embedding"); - SimpleQueryRenderContext renderContext = new SimpleQueryRenderContext(table, BindMarkersFactory.named(":", "p", 32), + SimpleBindings bindings = new SimpleBindings(); + SimpleEvaluationContext renderContext = new SimpleEvaluationContext(table, BindMarkersFactory.named(":", "p", 32), + bindings, converter, context.getRequiredPersistentEntity(WithEmbedding.class)); @Test @@ -50,11 +53,12 @@ void distanceLessThan() { Criteria embedding = PgSql .where("embedding", it -> it.distanceTo(Vector.of(1, 2, 3), PgSql.VectorSearchOperators.Distances::cosine)) - .lessThan(0.8); + .lessThan("0.8"); // converter converts to Number String sql = toSql(embedding); assertThat(sql).contains("with_embedding.the_embedding <=> :p0 < :p1"); + assertThat(bindings.getValues()).containsValue(new BigDecimal("0.8")); } @Test @@ -113,7 +117,7 @@ void arrayConcatColumnsAndContains() { void shouldRenderSourceFunction() { // SqlIdentifier to refer to columns? - Criteria arrayContains = PgSql.where(PgSql.function("array_ndims", CriteriaSource.ofColumn("someArray"))) + Criteria arrayContains = PgSql.where(PgSql.function("array_ndims", QueryExpression.column("someArray"))) .greaterThan(2); String sql = toSql(arrayContains); @@ -134,9 +138,7 @@ void arrayContains() { private String toSql(Criteria criteria) { - QueryExpression.QueryRenderContext contextualized = criteria.getExpression().contextualize(renderContext); - Expression expression = criteria.getExpression().render(contextualized); - + Expression expression = criteria.getExpression().evaluate(renderContext); return SqlRenderer.toString(Select.builder().select(expression).from(table).where(expression).build()); } diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/query/CriteriaSource.java b/spring-data-relational/src/test/java/org/springframework/data/relational/core/query/SimpleBindings.java similarity index 56% rename from spring-data-relational/src/main/java/org/springframework/data/relational/core/query/CriteriaSource.java rename to spring-data-relational/src/test/java/org/springframework/data/relational/core/query/SimpleBindings.java index 35776f5823..8bcef58779 100644 --- a/spring-data-relational/src/main/java/org/springframework/data/relational/core/query/CriteriaSource.java +++ b/spring-data-relational/src/test/java/org/springframework/data/relational/core/query/SimpleBindings.java @@ -15,19 +15,29 @@ */ package org.springframework.data.relational.core.query; -import org.springframework.data.relational.core.sql.SqlIdentifier; +import java.util.LinkedHashMap; +import java.util.Map; + +import org.springframework.data.relational.core.sql.BindMarker; /** * @author Mark Paluch */ -public interface CriteriaSource extends QueryExpression { +class SimpleBindings implements Bindings { + + private final Map values = new LinkedHashMap<>(); - static CriteriaSource ofColumn(String name) { - return new CriteriaSources.Column(name); + @Override + public void bind(BindMarker bindMarker, Object value) { + values.put(bindMarker, value); } - static CriteriaSource of(SqlIdentifier identifier) { - return new CriteriaSources.SqlIdentifierSource(identifier); + @Override + public void bind(BindMarker bindMarker, Object value, QueryExpression.ExpressionTypeContext typeContext) { + values.put(bindMarker, value); } + public Map getValues() { + return values; + } } From 3a94a941d2dd66931805c749c274b13ccc1122cb Mon Sep 17 00:00:00 2001 From: Mark Paluch Date: Wed, 25 Jun 2025 11:45:28 +0200 Subject: [PATCH 5/8] Polishing. --- .../springframework/data/relational/core/query/PgSqlImpl.java | 3 ++- .../data/relational/core/query/QueryExpression.java | 4 ++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/query/PgSqlImpl.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/query/PgSqlImpl.java index e24d9ae4d5..0ef3f6ae30 100644 --- a/spring-data-relational/src/main/java/org/springframework/data/relational/core/query/PgSqlImpl.java +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/query/PgSqlImpl.java @@ -293,7 +293,8 @@ public Expression evaluate(EvaluationContext context) { String operator = getOperator(); - return OperatorExpression.create(source.evaluate(context), operator, context.bind(vector)); + ExpressionTypeContext type = source.getType(context); + return OperatorExpression.create(source.evaluate(context), operator, context.withType(type).bind(vector)); } private String getOperator() { diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/query/QueryExpression.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/query/QueryExpression.java index 7645188214..32e57d066e 100644 --- a/spring-data-relational/src/main/java/org/springframework/data/relational/core/query/QueryExpression.java +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/query/QueryExpression.java @@ -235,6 +235,10 @@ default ExpressionTypeContext getAssignableType(ExpressionTypeContext other) { return ExpressionTypeContext.object(); } + + default boolean hasProperty() { + return getProperty() != null; + } } /** From 29c4d35876f3abd9d2a4c4cc895712dddcfbecb2 Mon Sep 17 00:00:00 2001 From: Mark Paluch Date: Wed, 25 Jun 2025 13:49:48 +0200 Subject: [PATCH 6/8] Explore --- .../data/relational/core/query/PgSqlUnitTests.java | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/spring-data-relational/src/test/java/org/springframework/data/relational/core/query/PgSqlUnitTests.java b/spring-data-relational/src/test/java/org/springframework/data/relational/core/query/PgSqlUnitTests.java index be49a026ba..317a5b815c 100644 --- a/spring-data-relational/src/test/java/org/springframework/data/relational/core/query/PgSqlUnitTests.java +++ b/spring-data-relational/src/test/java/org/springframework/data/relational/core/query/PgSqlUnitTests.java @@ -49,7 +49,8 @@ class PgSqlUnitTests { @Test void distanceLessThan() { - // distanceOf("embedding").l2() + + // where(distanceOf("embedding", vector).l2()).lessThan(…) Criteria embedding = PgSql .where("embedding", it -> it.distanceTo(Vector.of(1, 2, 3), PgSql.VectorSearchOperators.Distances::cosine)) @@ -74,7 +75,8 @@ void fieldIsActiveIsTrue() { @Test void jsonContainsAll() { - Criteria arrayContains = PgSql.where("tags").json().containsAll("electronics", "gaming"); + Criteria jsonContains1 = PgSql.where("tags").json().containsAll("electronics", "gaming"); + Criteria jsonContains2 = PgSql.where("tags").json(it -> it.containsAll("electronics", "gaming")); String sql = toSql(arrayContains); From 3821fc4022e44fe49fe9fc85d8989b6370ece175 Mon Sep 17 00:00:00 2001 From: Mark Paluch Date: Thu, 26 Jun 2025 16:05:46 +0200 Subject: [PATCH 7/8] Add support for Query Mapping. --- .../data/r2dbc/query/QueryMapper.java | 143 +++++---- .../r2dbc/query/R2dbcEvaluationContext.java | 105 ++++++ .../r2dbc/query/QueryMapperUnitTests.java | 37 +++ .../data/relational/core/query/Criteria.java | 27 +- .../core/query/CriteriaDefinition.java | 20 ++ .../core/query/CriteriaSources.java | 19 ++ ...ext.java => EvaluationContextSupport.java} | 70 ++-- .../core/query/NestedQueryExpression.java | 11 + .../data/relational/core/query/PgSql.java | 301 ++++++++++++++++-- .../data/relational/core/query/PgSqlImpl.java | 221 ++++++++++--- .../core/query/QueryExpression.java | 4 + .../data/relational/core/sql/BindMarker.java | 14 +- .../data/relational/core/sql/Comparison.java | 17 +- .../data/relational/core/sql/Conditions.java | 12 + .../data/relational/core/sql/SQL.java | 4 +- .../relational/core/query/PgSqlUnitTests.java | 54 +++- .../core/query/SimpleEvaluationContext.java | 79 +++++ 17 files changed, 932 insertions(+), 206 deletions(-) create mode 100644 spring-data-r2dbc/src/main/java/org/springframework/data/r2dbc/query/R2dbcEvaluationContext.java rename spring-data-relational/src/main/java/org/springframework/data/relational/core/query/{SimpleEvaluationContext.java => EvaluationContextSupport.java} (78%) create mode 100644 spring-data-relational/src/test/java/org/springframework/data/relational/core/query/SimpleEvaluationContext.java 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 f6ee60dd02..05f87f34c3 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 @@ -35,6 +35,7 @@ import org.springframework.data.relational.core.mapping.RelationalPersistentProperty; import org.springframework.data.relational.core.query.CriteriaDefinition; import org.springframework.data.relational.core.query.CriteriaDefinition.Comparator; +import org.springframework.data.relational.core.query.QueryExpression; import org.springframework.data.relational.core.query.ValueFunction; import org.springframework.data.relational.core.sql.*; import org.springframework.data.relational.domain.SqlSort; @@ -48,6 +49,7 @@ import org.springframework.r2dbc.core.binding.MutableBindings; import org.springframework.util.Assert; import org.springframework.util.ClassUtils; +import org.springframework.util.StringUtils; /** * Maps {@link CriteriaDefinition} and {@link Sort} objects considering mapping metadata and dialect-specific @@ -296,31 +298,58 @@ private Condition combine(CriteriaDefinition criteria, @Nullable Condition curre private Condition mapCondition(CriteriaDefinition criteria, MutableBindings bindings, Table table, @Nullable RelationalPersistentEntity entity) { - Field propertyField = createPropertyField(entity, criteria.getColumn(), this.mappingContext); - Column column = table.column(propertyField.getMappedColumnName()); - TypeInformation actualType = propertyField.getTypeHint().getRequiredActualType(); - Object mappedValue; - Class typeHint; + TypeInformation typeHint; + Class targetType; + Expression expression; + String nameHint; + + if (criteria.hasExpression()) { + + R2dbcEvaluationContext context = new R2dbcEvaluationContext(table, converter, entity, bindings); + QueryExpression queryExpression = criteria.getExpression(); + typeHint = queryExpression.getType(context).getTargetType(); + + expression = queryExpression.evaluate(context); + nameHint = queryExpression.getNameHint(); + + if (criteria.getComparator() == null) { + return Conditions.from(expression); + } + + } else if (criteria.hasColumn()) { + + Field propertyField = createPropertyField(entity, criteria.getColumn(), this.mappingContext); + Column column = table.column(propertyField.getMappedColumnName()); + expression = column; + nameHint = column.getName().getReference(); + typeHint = propertyField.getTypeHint(); + + } else { + throw new IllegalStateException("Cannot map empty Criteria"); + } + + TypeInformation actualType = typeHint.getRequiredActualType(); Comparator comparator = criteria.getComparator(); + if (criteria.getValue() instanceof Parameter parameter) { - mappedValue = convertValue(comparator, parameter.getValue(), propertyField.getTypeHint()); - typeHint = getTypeHint(mappedValue, actualType.getType(), parameter); + mappedValue = convertValue(comparator, parameter.getValue(), typeHint); + targetType = getTypeHint(mappedValue, actualType.getType(), parameter); } else if (criteria.getValue() instanceof ValueFunction valueFunction) { - mappedValue = valueFunction.map(v -> convertValue(comparator, v, propertyField.getTypeHint())) - .apply(getEscaper(comparator)); + mappedValue = valueFunction.map(v -> convertValue(comparator, v, typeHint)).apply(getEscaper(comparator)); - typeHint = actualType.getType(); + targetType = actualType.getType(); } else { - mappedValue = convertValue(comparator, criteria.getValue(), propertyField.getTypeHint()); - typeHint = actualType.getType(); + mappedValue = convertValue(comparator, criteria.getValue(), typeHint); + targetType = actualType.getType(); } - return createCondition(column, mappedValue, typeHint, bindings, comparator, criteria.isIgnoreCase()); + return createCondition(expression, nameHint, mappedValue, targetType, bindings, comparator, + criteria.isIgnoreCase()); } private Escaper getEscaper(Comparator comparator) { @@ -393,32 +422,32 @@ protected MappingContext, RelationalPers return this.mappingContext; } - private Condition createCondition(Column column, @Nullable Object mappedValue, Class valueType, - MutableBindings bindings, Comparator comparator, boolean ignoreCase) { + private Condition createCondition(Expression source, String nameHint, @Nullable Object mappedValue, + Class valueType, MutableBindings bindings, Comparator comparator, boolean ignoreCase) { if (comparator.equals(Comparator.IS_NULL)) { - return column.isNull(); + return Conditions.isNull(source); } if (comparator.equals(Comparator.IS_NOT_NULL)) { - return column.isNotNull(); + return Conditions.isNull(source).not(); } if (comparator == Comparator.IS_TRUE) { - Expression bind = booleanBind(column, mappedValue, valueType, bindings, ignoreCase); - return column.isEqualTo(bind); + Expression bind = booleanBind(nameHint, mappedValue, valueType, bindings, ignoreCase); + return Conditions.isEqual(source, bind); } if (comparator == Comparator.IS_FALSE) { - Expression bind = booleanBind(column, mappedValue, valueType, bindings, ignoreCase); - return column.isEqualTo(bind); + Expression bind = booleanBind(nameHint, mappedValue, valueType, bindings, ignoreCase); + return Conditions.isEqual(source, bind); } - Expression columnExpression = column; + Expression columnExpression = source; if (ignoreCase) { - columnExpression = Functions.upper(column); + columnExpression = Functions.upper(source); } if (comparator == Comparator.NOT_IN || comparator == Comparator.IN) { @@ -432,7 +461,7 @@ private Condition createCondition(Column column, @Nullable Object mappedValue, C for (Object o : (Iterable) mappedValue) { - BindMarker bindMarker = bindings.nextMarker(column.getName().getReference()); + BindMarker bindMarker = bindMarker(nameHint, bindings); expressions.add(bind(o, valueType, bindings, bindMarker)); } @@ -440,7 +469,8 @@ private Condition createCondition(Column column, @Nullable Object mappedValue, C } else { - BindMarker bindMarker = bindings.nextMarker(column.getName().getReference()); + BindMarker bindMarker = bindMarker(nameHint, bindings); + ; Expression expression = bind(mappedValue, valueType, bindings, bindMarker); condition = Conditions.in(columnExpression, expression); @@ -457,53 +487,51 @@ private Condition createCondition(Column column, @Nullable Object mappedValue, C Pair pair = (Pair) mappedValue; - Expression begin = bind(pair.getFirst(), valueType, bindings, - bindings.nextMarker(column.getName().getReference()), ignoreCase); - Expression end = bind(pair.getSecond(), valueType, bindings, bindings.nextMarker(column.getName().getReference()), - ignoreCase); + Expression begin = bind(pair.getFirst(), valueType, bindings, bindMarker(nameHint, bindings), ignoreCase); + Expression end = bind(pair.getSecond(), valueType, bindings, bindMarker(nameHint, bindings), ignoreCase); return comparator == Comparator.BETWEEN ? Conditions.between(columnExpression, begin, end) : Conditions.notBetween(columnExpression, begin, end); } - BindMarker bindMarker = bindings.nextMarker(column.getName().getReference()); + BindMarker bindMarker = bindMarker(nameHint, bindings); + ; - switch (comparator) { - case EQ: { + return switch (comparator) { + case EQ -> { Expression expression = bind(mappedValue, valueType, bindings, bindMarker, ignoreCase); - return Conditions.isEqual(columnExpression, expression); + yield Conditions.isEqual(columnExpression, expression); } - case NEQ: { + case NEQ -> { Expression expression = bind(mappedValue, valueType, bindings, bindMarker, ignoreCase); - return Conditions.isEqual(columnExpression, expression).not(); + yield Conditions.isEqual(columnExpression, expression).not(); } - case LT: { + case LT -> { Expression expression = bind(mappedValue, valueType, bindings, bindMarker); - return column.isLess(expression); + yield Conditions.isLess(source, expression); } - case LTE: { + case LTE -> { Expression expression = bind(mappedValue, valueType, bindings, bindMarker); - return column.isLessOrEqualTo(expression); + yield Conditions.isLessOrEqualTo(source, expression); } - case GT: { + case GT -> { Expression expression = bind(mappedValue, valueType, bindings, bindMarker); - return column.isGreater(expression); + yield Conditions.isGreater(source, expression); } - case GTE: { + case GTE -> { Expression expression = bind(mappedValue, valueType, bindings, bindMarker); - return column.isGreaterOrEqualTo(expression); + yield Conditions.isGreaterOrEqualTo(source, expression); } - case LIKE: { + case LIKE -> { Expression expression = bind(mappedValue, valueType, bindings, bindMarker, ignoreCase); - return Conditions.like(columnExpression, expression); + yield Conditions.like(columnExpression, expression); } - case NOT_LIKE: { + case NOT_LIKE -> { Expression expression = bind(mappedValue, valueType, bindings, bindMarker, ignoreCase); - return Conditions.notLike(columnExpression, expression); + yield Conditions.notLike(columnExpression, expression); } - default: - throw new UnsupportedOperationException("Comparator " + comparator + " not supported"); - } + default -> throw new UnsupportedOperationException("Comparator " + comparator + " not supported"); + }; } Field createPropertyField(@Nullable RelationalPersistentEntity entity, SqlIdentifier key) { @@ -515,10 +543,6 @@ Field createPropertyField(@Nullable RelationalPersistentEntity entity, SqlIde return entity == null ? new Field(key) : new MetadataBackedField(key, entity, mappingContext); } - Class getTypeHint(@Nullable Object mappedValue, Class propertyType) { - return propertyType; - } - Class getTypeHint(@Nullable Object mappedValue, Class propertyType, Parameter parameter) { if (mappedValue == null || propertyType.equals(Object.class)) { @@ -550,13 +574,18 @@ private Expression bind(@Nullable Object mappedValue, Class valueType, Mutabl : SQL.bindMarker(bindMarker.getPlaceholder()); } - private Expression booleanBind(Column column, Object mappedValue, Class valueType, MutableBindings bindings, - boolean ignoreCase) { - BindMarker bindMarker = bindings.nextMarker(column.getName().getReference()); + private Expression booleanBind(@Nullable String nameHint, Object mappedValue, Class valueType, + MutableBindings bindings, boolean ignoreCase) { + + BindMarker bindMarker = bindMarker(nameHint, bindings); return bind(mappedValue, valueType, bindings, bindMarker, ignoreCase); } + private static BindMarker bindMarker(@Nullable String nameHint, MutableBindings bindings) { + return StringUtils.hasText(nameHint) ? bindings.nextMarker(nameHint) : bindings.nextMarker(); + } + /** * Value object to represent a field and its meta-information. */ diff --git a/spring-data-r2dbc/src/main/java/org/springframework/data/r2dbc/query/R2dbcEvaluationContext.java b/spring-data-r2dbc/src/main/java/org/springframework/data/r2dbc/query/R2dbcEvaluationContext.java new file mode 100644 index 0000000000..267ca164d6 --- /dev/null +++ b/spring-data-r2dbc/src/main/java/org/springframework/data/r2dbc/query/R2dbcEvaluationContext.java @@ -0,0 +1,105 @@ +/* + * Copyright 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.r2dbc.query; + +import org.springframework.data.relational.core.conversion.RelationalConverter; +import org.springframework.data.relational.core.mapping.RelationalPersistentEntity; +import org.springframework.data.relational.core.query.EvaluationContextSupport; +import org.springframework.data.relational.core.query.QueryExpression; +import org.springframework.data.relational.core.sql.BindMarker; +import org.springframework.data.relational.core.sql.Table; +import org.springframework.lang.Nullable; +import org.springframework.r2dbc.core.binding.BindTarget; +import org.springframework.r2dbc.core.binding.MutableBindings; + +/** + * R2DBC-specific {@link QueryExpression.EvaluationContext} implementation. + * + * @author Mark Paluch + */ +class R2dbcEvaluationContext extends EvaluationContextSupport { + + private final QueryExpression.ExpressionTypeContext type; + private final MutableBindings bindings; + + public R2dbcEvaluationContext(Table table, RelationalConverter converter, + @Nullable RelationalPersistentEntity entity, MutableBindings bindings) { + + super(table, converter, entity); + + this.type = QueryExpression.ExpressionTypeContext.object(); + this.bindings = bindings; + } + + public R2dbcEvaluationContext(R2dbcEvaluationContext previous, QueryExpression.ExpressionTypeContext type) { + + super(previous); + + this.type = type; + this.bindings = previous.bindings; + } + + @Override + public QueryExpression.EvaluationContext withType(QueryExpression.ExpressionTypeContext type) { + return new R2dbcEvaluationContext(this, type); + } + + @Override + public BindMarker bind(Object value) { + + Object valueToUse = getConverter().writeValue(value, type.getTargetType()); + return new BindMarkerAdapter(bindings.bind(valueToUse)); + } + + @Override + public BindMarker bind(String name, Object value) { + + Object valueToUse = getConverter().writeValue(value, type.getTargetType()); + org.springframework.r2dbc.core.binding.BindMarker bindMarker = bindings.nextMarker(name); + bindings.bind(bindMarker, valueToUse); + return new BindMarkerAdapter(bindMarker); + } + + private static class BindMarkerAdapter extends BindMarker + implements org.springframework.r2dbc.core.binding.BindMarker { + + private final org.springframework.r2dbc.core.binding.BindMarker bindMarker; + + public BindMarkerAdapter(org.springframework.r2dbc.core.binding.BindMarker bindMarker) { + this.bindMarker = bindMarker; + } + + @Override + public String getPlaceholder() { + return bindMarker.getPlaceholder(); + } + + @Override + public void bind(BindTarget bindTarget, Object value) { + bindMarker.bind(bindTarget, value); + } + + @Override + public void bindNull(BindTarget bindTarget, Class valueType) { + bindMarker.bindNull(bindTarget, valueType); + } + + @Override + public String toString() { + return "[" + getPlaceholder() + "]"; + } + } +} 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 76ceb3b2e5..5c84f7eedc 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 @@ -26,6 +26,7 @@ import java.util.Objects; import org.junit.jupiter.api.Test; + import org.springframework.core.convert.converter.Converter; import org.springframework.data.domain.Sort; import org.springframework.data.r2dbc.convert.MappingR2dbcConverter; @@ -37,6 +38,7 @@ import org.springframework.data.r2dbc.mapping.R2dbcMappingContext; import org.springframework.data.relational.core.mapping.Column; import org.springframework.data.relational.core.query.Criteria; +import org.springframework.data.relational.core.query.PgSql; import org.springframework.data.relational.core.sql.Expression; import org.springframework.data.relational.core.sql.Functions; import org.springframework.data.relational.core.sql.OrderByField; @@ -45,6 +47,8 @@ import org.springframework.r2dbc.core.Parameter; import org.springframework.r2dbc.core.binding.BindMarkersFactory; import org.springframework.r2dbc.core.binding.BindTarget; +import org.springframework.r2dbc.core.binding.Bindings; + import org.testcontainers.shaded.com.fasterxml.jackson.databind.JsonNode; import org.testcontainers.shaded.com.fasterxml.jackson.databind.node.TextNode; @@ -541,6 +545,39 @@ void shouldMapJsonNodeListToString() { assertThat(bindings.getBindings().iterator().next().getValue()).isEqualTo("foo"); } + @Test // GH-1953 + void shouldMapDialectCriteria() { + + Criteria criteria = PgSql.PgCriteria.where("jsonNode").json(it -> it.exists("some_key")); + + BoundCondition bindings = map(criteria); + + assertThat(bindings.getCondition()).hasToString("person.json_node ? [$1]"); + assertThat(bindings.getBindings().iterator().next().getValue()).isEqualTo("some_key"); + } + + @Test // GH-1953 + void shouldMapExtractedDialectCriteria() { + + Criteria criteria = PgSql.PgCriteria.where("jsonNode", operators -> operators.json().index(1)).is("some_key"); + + BoundCondition bindings = map(criteria); + + assertThat(bindings.getCondition()).hasToString("person.json_node -> [$1] = [$2]"); + assertThat(bindings.getBindings()).map(Bindings.Binding::getValue).containsExactly(1, "some_key"); + } + + @Test // GH-1953 + void shouldMapSelfContainedExpression() { + + Criteria criteria = PgSql.PgCriteria.where("jsonNode", operators -> operators.json().index(1)).asBoolean(); + + BoundCondition bindings = map(criteria); + + assertThat(bindings.getCondition()).hasToString("(person.json_node -> [$1])::boolean"); + assertThat(bindings.getBindings()).map(Bindings.Binding::getValue).containsExactly(1); + } + private BoundCondition map(Criteria criteria) { BindMarkersFactory markers = BindMarkersFactory.indexed("$", 1); 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 51c3cca018..135dbe908d 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 @@ -87,7 +87,7 @@ protected Criteria(QueryExpression queryExpression, @Nullable SqlIdentifier colu protected Criteria(@Nullable Criteria previous, Combinator combinator, List group, @Nullable QueryExpression queryExpression, @Nullable SqlIdentifier column, @Nullable Comparator comparator, @Nullable Object value) { - this(previous, combinator, group, null, column, comparator, value, false); + this(previous, combinator, group, queryExpression, column, comparator, value, false); } protected Criteria(@Nullable Criteria previous, Combinator combinator, List group, @@ -370,6 +370,10 @@ private boolean doIsEmpty() { return false; } + if (this.queryExpression != null) { + return false; + } + for (CriteriaDefinition criteria : group) { if (!criteria.isEmpty()) { @@ -537,7 +541,13 @@ private void render(CriteriaDefinition criteria, StringBuilder stringBuilder) { return; } - stringBuilder.append(criteria.getColumn().toSql(IdentifierProcessing.NONE)).append(' ') + stringBuilder.append(getLhs(criteria)); + + if (criteria.getComparator() == null) { + return; + } + + stringBuilder.append(' ') .append(criteria.getComparator().getComparator()); switch (criteria.getComparator()) { @@ -563,6 +573,19 @@ private void render(CriteriaDefinition criteria, StringBuilder stringBuilder) { } } + private static String getLhs(CriteriaDefinition criteria) { + + if (criteria.hasExpression()) { + return criteria.getExpression().toString(); + } + + if (criteria.hasColumn()) { + return criteria.getColumn().toSql(IdentifierProcessing.NONE); + } + + return "?"; + } + private static String renderValue(@Nullable Object value) { if (value instanceof Number) { 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 4072830bfd..2603719714 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 @@ -85,15 +85,35 @@ static CriteriaDefinition from(List criteria) { List getGroup(); + /** + * @return + * @since 4.0 + */ @Nullable QueryExpression getExpression(); + /** + * @return returns {@literal true} if this criteria definition has a {@link QueryExpression}. + * @since 4.0 + */ + default boolean hasExpression() { + return getExpression() != null; + } + /** * @return the column/property name. */ @Nullable SqlIdentifier getColumn(); + /** + * @return returns {@literal true} if this criteria definition has a column/property name. + * @since 4.0 + */ + default boolean hasColumn() { + return getColumn() != null; + } + /** * @return {@link Criteria.Comparator}. */ diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/query/CriteriaSources.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/query/CriteriaSources.java index 2ba3c2ecf8..4c0328a073 100644 --- a/spring-data-relational/src/main/java/org/springframework/data/relational/core/query/CriteriaSources.java +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/query/CriteriaSources.java @@ -16,6 +16,7 @@ package org.springframework.data.relational.core.query; import org.springframework.data.relational.core.sql.Expression; +import org.springframework.data.relational.core.sql.IdentifierProcessing; import org.springframework.data.relational.core.sql.SqlIdentifier; /** @@ -39,11 +40,20 @@ public ExpressionTypeContext getType(EvaluationContext context) { return context.getColumn(name); } + @Override + public String getNameHint() { + return name; + } + @Override public Expression evaluate(EvaluationContext context) { return context.getColumn(name).toExpression(); } + @Override + public String toString() { + return name; + } } /** @@ -58,12 +68,21 @@ public ExpressionTypeContext getType(EvaluationContext context) { return context.getColumn(identifier); } + @Override + public String getNameHint() { + return identifier.getReference(); + } @Override public Expression evaluate(EvaluationContext context) { return context.getColumn(identifier).toExpression(); } + @Override + public String toString() { + return identifier.toSql(IdentifierProcessing.NONE); + } + } } diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/query/SimpleEvaluationContext.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/query/EvaluationContextSupport.java similarity index 78% rename from spring-data-relational/src/main/java/org/springframework/data/relational/core/query/SimpleEvaluationContext.java rename to spring-data-relational/src/main/java/org/springframework/data/relational/core/query/EvaluationContextSupport.java index dcf3ac1350..d27501f7a1 100644 --- a/spring-data-relational/src/main/java/org/springframework/data/relational/core/query/SimpleEvaluationContext.java +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/query/EvaluationContextSupport.java @@ -22,12 +22,9 @@ import org.springframework.data.mapping.PropertyPath; import org.springframework.data.mapping.PropertyReferenceException; import org.springframework.data.mapping.context.MappingContext; -import org.springframework.data.relational.core.binding.BindMarkers; -import org.springframework.data.relational.core.binding.BindMarkersFactory; import org.springframework.data.relational.core.conversion.RelationalConverter; import org.springframework.data.relational.core.mapping.RelationalPersistentEntity; import org.springframework.data.relational.core.mapping.RelationalPersistentProperty; -import org.springframework.data.relational.core.sql.BindMarker; import org.springframework.data.relational.core.sql.Column; import org.springframework.data.relational.core.sql.Expression; import org.springframework.data.relational.core.sql.SqlIdentifier; @@ -39,55 +36,45 @@ import org.springframework.util.ClassUtils; /** + * Support class for {@link QueryExpression.EvaluationContext} implementations. + * * @author Mark Paluch + * @since 4.0 */ -public class SimpleEvaluationContext implements QueryExpression.EvaluationContext { +public abstract class EvaluationContextSupport implements QueryExpression.EvaluationContext { private final Table table; - private final BindMarkers bindMarkers; - private final Bindings bindings; private final RelationalConverter converter; - private final RelationalPersistentEntity entity; - private final QueryExpression.ExpressionTypeContext type; + private final @Nullable RelationalPersistentEntity entity; - public SimpleEvaluationContext(Table table, BindMarkersFactory bindMarkersFactory, Bindings bindings, - RelationalConverter converter, - RelationalPersistentEntity entity) { + public EvaluationContextSupport(Table table, RelationalConverter converter, + @Nullable RelationalPersistentEntity entity) { this.table = table; - this.bindMarkers = bindMarkersFactory.create(); - this.bindings = bindings; this.converter = converter; this.entity = entity; - this.type = QueryExpression.ExpressionTypeContext.object(); } - public SimpleEvaluationContext(Table table, BindMarkers bindMarkers, Bindings bindings, RelationalConverter converter, - RelationalPersistentEntity entity, QueryExpression.ExpressionTypeContext type) { - - Assert.notNull(table, "Table must not be null"); - Assert.notNull(type, "Type must not be null"); + protected EvaluationContextSupport(EvaluationContextSupport previous) { - this.table = table; - this.bindMarkers = bindMarkers; - this.bindings = bindings; - this.converter = converter; - this.entity = entity; - this.type = type; + this.table = previous.table; + this.converter = previous.converter; + this.entity = previous.entity; } - public Bindings getBindings() { - return bindings; + protected RelationalConverter getConverter() { + return converter; } private Field createPropertyField(SqlIdentifier key) { - return new MetadataBackedField(key, entity, converter.getMappingContext()); + return entity != null ? new MetadataBackedField(key, entity, converter.getMappingContext()) : new Field(key); } @Override public QueryExpression.MappedColumn getColumn(SqlIdentifier identifier) { Field propertyField = createPropertyField(identifier); + TableLike table = getTable(identifier); return new DefaultMappedColumn(table, table.column(propertyField.getMappedColumnName()), propertyField); } @@ -96,35 +83,22 @@ public QueryExpression.MappedColumn getColumn(SqlIdentifier identifier) { public QueryExpression.MappedColumn getColumn(String column) { Field propertyField = createPropertyField(SqlIdentifier.unquoted(column)); + TableLike table = getTable(column); return new DefaultMappedColumn(table, table.column(propertyField.getMappedColumnName()), propertyField); } - @Override - public QueryExpression.EvaluationContext withType(QueryExpression.ExpressionTypeContext type) { - return new SimpleEvaluationContext(table, bindMarkers, bindings, converter, entity, type); + protected TableLike getTable(SqlIdentifier identifier) { + return table; } - @Override - public BindMarker bind(Object value) { - - BindMarker bindMarker = bindMarkers.next(); - - bindings.bind(bindMarker, converter.writeValue(value, type.getTargetType())); - - return bindMarker; + protected TableLike getTable(String column) { + return table; } @Override - public BindMarker bind(String name, Object value) { - - BindMarker bindMarker = bindMarkers.next(name); - - bindings.bind(bindMarker, converter.writeValue(value, type.getTargetType())); - - return bindMarker; - } + public abstract QueryExpression.EvaluationContext withType(QueryExpression.ExpressionTypeContext type); - class DefaultMappedColumn implements QueryExpression.MappedColumn { + static class DefaultMappedColumn implements QueryExpression.MappedColumn { private final TableLike table; private final Column column; diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/query/NestedQueryExpression.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/query/NestedQueryExpression.java index 0640b9f1b2..1d59aa87f6 100644 --- a/spring-data-relational/src/main/java/org/springframework/data/relational/core/query/NestedQueryExpression.java +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/query/NestedQueryExpression.java @@ -17,6 +17,7 @@ import org.springframework.data.relational.core.sql.Expression; import org.springframework.data.relational.core.sql.Expressions; +import org.springframework.lang.Nullable; /** * Query expression that nests another {@link QueryExpression}. This is used to ensure that the nested expression @@ -41,9 +42,19 @@ public ExpressionTypeContext getType(EvaluationContext context) { return expression.getType(context); } + @Nullable + @Override + public String getNameHint() { + return expression.getNameHint(); + } + @Override public Expression evaluate(EvaluationContext context) { return Expressions.nest(expression.evaluate(context)); } + @Override + public String toString() { + return "(" + expression + ")"; + } } diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/query/PgSql.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/query/PgSql.java index ed6e3f0a8f..d17051f10e 100644 --- a/spring-data-relational/src/main/java/org/springframework/data/relational/core/query/PgSql.java +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/query/PgSql.java @@ -16,6 +16,7 @@ package org.springframework.data.relational.core.query; import java.util.Arrays; +import java.util.Collection; import java.util.Collections; import java.util.List; import java.util.Map; @@ -28,7 +29,10 @@ import org.springframework.util.Assert; /** + * Dialect-specific extension for the Postgres database dialect. + * * @author Mark Paluch + * @since 4.0 */ public final class PgSql { @@ -58,7 +62,7 @@ public static PostgresQueryExpression function(String functionName, Object... ar * @return */ public static PgCriteria.PostgresCriteriaStep where(String column) { - return new PgSqlImpl.DefaultPostgresCriteriaStep(column); + return PgCriteria.where(column); } /** @@ -72,8 +76,7 @@ public static PgCriteria.PostgresCriteriaStep where(String column) { */ public static PgCriteria.PostgresCriteriaStep where(String column, Function wrappingFunction) { - - return where(new CriteriaSources.Column(column), wrappingFunction); + return PgCriteria.where(column, wrappingFunction); } /** @@ -84,7 +87,7 @@ public static PgCriteria.PostgresCriteriaStep where(String column, * @return */ public static PgCriteria.PostgresCriteriaStep where(QueryExpression expression) { - return new PgSqlImpl.DefaultPostgresCriteriaStep(expression); + return PgCriteria.where(expression); } /** @@ -98,9 +101,7 @@ public static PgCriteria.PostgresCriteriaStep where(QueryExpression expression) */ public static PgCriteria.PostgresCriteriaStep where(QueryExpression expression, Function wrappingFunction) { - - PgSqlImpl.DefaultOperators functions = new PgSqlImpl.DefaultOperators(expression); - return where(wrappingFunction.apply(functions)); + return PgCriteria.where(expression, wrappingFunction); } /** @@ -139,6 +140,21 @@ public QueryExpression jsonbOf(Map jsonObject) { }; } + public static VectorSearchFunctions vectorSearch() { + return new VectorSearchFunctions() { + @Override + public VectorSearchDistanceStep distanceOf(String column, Vector vector) { + return new VectorSearchDistanceStep() { + @Override + public PostgresQueryExpression using(ScoringFunction scoringFunction) { + return new PgSqlImpl.DistanceFunction(QueryExpression.column(column), scoringFunction, vector); + } + + }; + } + }; + } + /** * Postgres-specific {@link Criteria} implementation that provides access to Postgres-specific operators and * functions. @@ -160,6 +176,59 @@ protected PgCriteria(@Nullable Criteria previous, Combinator combinator, List wrappingFunction) { + + return where(new CriteriaSources.Column(column), wrappingFunction); + } + + /** + * Entry point for creating a {@link PgCriteria.PostgresCriteriaStep} for the given {@link QueryExpression + * expression}. + * + * @param expression + * @return + */ + public static PgCriteria.PostgresCriteriaStep where(QueryExpression expression) { + return new PgSqlImpl.DefaultPostgresCriteriaStep(expression); + } + + /** + * Entry point for creating a {@link PgCriteria.PostgresCriteriaStep} for the given {@link QueryExpression} that + * shall be wrapped by a wrapping function to process the column contents before its use in a downstream condition + * or expression. + * + * @param expression + * @param wrappingFunction + * @return + */ + public static PgCriteria.PostgresCriteriaStep where(QueryExpression expression, + Function wrappingFunction) { + + PgSqlImpl.DefaultOperators functions = new PgSqlImpl.DefaultOperators(expression); + return where(wrappingFunction.apply(functions)); + } + @Override public PgCriteria.PostgresCriteriaStep and(String column) { @@ -225,8 +294,14 @@ public interface PostgresCriteriaStep extends CriteriaStep { * * @return */ - PostgresJsonCriteriaStep json(); + PgCriteria json(Function criteriaFunction); + /** + * Consider the previous expression as expression evaluating a boolean result. + * + * @return + */ + PgCriteria asBoolean(); } /** @@ -300,12 +375,18 @@ default PgCriteria overlaps(Object... values) { */ public interface PostgresJsonCriteriaStep { + /** + * {@code ?} operator for JSON exists. + * + * @param column + * @return + */ default PgCriteria exists(String column) { return exists(QueryExpression.column(column)); } /** - * {@code ?} operator for JSONB containment. + * {@code ?} operator for JSON exists. * * @param expression * @return @@ -313,7 +394,7 @@ default PgCriteria exists(String column) { PgCriteria exists(QueryExpression expression); /** - * {@code ?} operator for JSONB containment. + * {@code ?} operator for JSON exists. * * @param value * @return @@ -322,28 +403,74 @@ default PgCriteria exists(Object value) { return exists(new PgSqlImpl.ValueExpression(value)); } + /** + * {@code ?|} operator for JSON contains. + * + * @param value + * @return + */ default PgCriteria contains(String field) { return contains(QueryExpression.column(field)); } + /** + * {@code ?|} operator for JSON contains. + * + * @param value + * @return + */ PgCriteria contains(Object value); + /** + * {@code ?&} operator for JSON contains all. + * + * @param values + * @return + */ default PgCriteria containsAll(Object... values) { return containsAll(Arrays.asList(values)); } - PgCriteria containsAll(Iterable values); + /** + * {@code ?&} operator for JSON contains all. + * + * @param values + * @return + */ + PgCriteria containsAll(Collection values); + /** + * {@code ?|} operator for JSON contains any. + * + * @param values + * @return + */ default PgCriteria containsAny(Object... values) { return containsAny(Arrays.asList(values)); } - PgCriteria containsAny(Iterable values); + /** + * {@code ?|} operator for JSON contains any. + * + * @param values + * @return + */ + PgCriteria containsAny(Collection values); - // @? + /** + * Does JSON path return any item for the specified JSON value using the {@code @?} operator. + * + * @param jsonPath + * @return + */ PgCriteria jsonPathMatches(String jsonPath); - // @@ + /** + * Returns the result of a JSON path predicate check for the specified JSON value using the {@code @@} operator. + * + * @param jsonPath + * @return + */ PgCriteria jsonPath(String jsonPath); } @@ -387,6 +514,55 @@ public interface JsonFunctions { } + /** + * Postgres-specific Vector Search functions. + */ + public interface VectorSearchFunctions { + + /** + * Start building a Vector Search operator given a column and a reference {@link Vector} + * + * @param column + * @param vector + * @return + */ + VectorSearchDistanceStep distanceOf(String column, Vector vector); + + interface VectorSearchDistanceStep { + + default PostgresQueryExpression l2() { + return using(ScoringFunction.euclidean()); + } + + default PostgresQueryExpression innerProduct() { + return using(ScoringFunction.dotProduct()); + } + + default PostgresQueryExpression cosine() { + return using(ScoringFunction.cosine()); + } + + default PostgresQueryExpression l1() { + return using(of("<+>")); + } + + default PostgresQueryExpression hamming() { + return using(of("<~>")); + } + + default PostgresQueryExpression jaccard() { + return using(of("<%>")); + } + + default ScoringFunction of(String operator) { + return () -> operator; + } + + PostgresQueryExpression using(ScoringFunction scoringFunction); + + } + } + /** * Entrypoint for Postgres-specific functions. */ @@ -630,8 +806,8 @@ default PostgresQueryExpression asString() { * * @return the casted expression. */ - default PostgresQueryExpression asJson() { - return as("json"); + default PostgresJsonQueryExpression asJson() { + return (PostgresJsonQueryExpression) as("json"); } /** @@ -639,8 +815,12 @@ default PostgresQueryExpression asJson() { * * @return the casted expression. */ - default PostgresQueryExpression asJsonb() { - return as("jsonb"); + default PostgresJsonQueryExpression asJsonb() { + return (PostgresJsonQueryExpression) as("jsonb"); + } + + default PostgresQueryExpression json(Function jsonFunction) { + return jsonFunction.apply((PostgresJsonQueryExpression) this); } /** @@ -667,4 +847,89 @@ default PostgresQueryExpression element(String key) { } + /** + * JSON-specific query expression that allows to append Postgres-specific JSON operators and functions. + */ + public interface PostgresJsonQueryExpression { + + /** + * {@code ?} operator for JSON exists. + * + * @param expression + * @return + */ + PostgresQueryExpression exists(QueryExpression expression); + + /** + * {@code ?} operator for JSON exists. + * + * @param value + * @return + */ + default PostgresQueryExpression exists(Object value) { + return exists(new PgSqlImpl.ValueExpression(value)); + } + + /** + * {@code ?|} operator for JSON contains. + * + * @param value + * @return + */ + PostgresQueryExpression contains(Object value); + + /** + * {@code ?&} operator for JSON contains all. + * + * @param values + * @return + */ + default PostgresQueryExpression containsAll(Object... values) { + return containsAll(Arrays.asList(values)); + } + + /** + * {@code ?&} operator for JSON contains all. + * + * @param values + * @return + */ + PostgresQueryExpression containsAll(Collection values); + + /** + * {@code ?|} operator for JSON contains any. + * + * @param values + * @return + */ + default PostgresQueryExpression containsAny(Object... values) { + return containsAny(Arrays.asList(values)); + } + + /** + * {@code ?|} operator for JSON contains any. + * + * @param values + * @return + */ + PostgresQueryExpression containsAny(Collection values); + + /** + * Does JSON path return any item for the specified JSON value using the {@code @?} operator. + * + * @param jsonPath + * @return + */ + PostgresQueryExpression jsonPathMatches(String jsonPath); + + /** + * Returns the result of a JSON path predicate check for the specified JSON value using the {@code @@} operator. + * + * @param jsonPath + * @return + */ + PostgresQueryExpression jsonPath(String jsonPath); + + } + } diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/query/PgSqlImpl.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/query/PgSqlImpl.java index 0ef3f6ae30..3d7b198c26 100644 --- a/spring-data-relational/src/main/java/org/springframework/data/relational/core/query/PgSqlImpl.java +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/query/PgSqlImpl.java @@ -17,6 +17,7 @@ import java.util.ArrayList; import java.util.Arrays; +import java.util.Collection; import java.util.Iterator; import java.util.List; import java.util.Locale; @@ -25,6 +26,7 @@ import org.springframework.data.domain.ScoringFunction; import org.springframework.data.domain.Vector; +import org.springframework.data.relational.core.query.PgSql.PostgresJsonQueryExpression; import org.springframework.data.relational.core.sql.ArrayIndexExpression; import org.springframework.data.relational.core.sql.BaseFunction; import org.springframework.data.relational.core.sql.BindMarker; @@ -35,7 +37,10 @@ import org.springframework.data.relational.core.sql.PostfixExpression; import org.springframework.data.relational.core.sql.SimpleFunction; import org.springframework.data.relational.core.sql.TupleExpression; +import org.springframework.lang.NonNull; import org.springframework.lang.Nullable; +import org.springframework.util.ObjectUtils; +import org.springframework.util.StringUtils; /** * Implementation of the Postgres dialect for criteria and query expressions. @@ -90,39 +95,41 @@ public PgSql.PgCriteria overlaps(QueryExpression expression) { } @Override - public PgSql.PgCriteria.PostgresJsonCriteriaStep json() { - return new PgSql.PgCriteria.PostgresJsonCriteriaStep() { + public PgSql.PgCriteria json(Function criteriaFunction) { + return createCriteria(criteriaFunction.apply(DefaultPostgresJsonQueryExpression.get(getLhs()))); + } - @Override - public PgSql.PgCriteria exists(QueryExpression expression) { - return createCriteria("?", expression); - } + @Override + public PgSql.PgCriteria asBoolean() { + return createCriteria(DefaultPostgresQueryExpression.queryExpression(getLhs()).asBoolean()); + } + } - @Override - public PgSql.PgCriteria contains(Object value) { - return createCriteria("@>", value); - } + private record DefaultPostgresQueryExpression(QueryExpression source) implements PgSql.PostgresQueryExpression { - @Override - public PgSql.PgCriteria containsAll(Iterable values) { - return createCriteria("?&", ArrayExpression.expressionOrWrap(values)); - } + public static PgSql.PostgresQueryExpression queryExpression(QueryExpression source) { + return source instanceof PgSql.PostgresQueryExpression pq ? pq : new DefaultPostgresQueryExpression(source); + } - @Override - public PgSql.PgCriteria containsAny(Iterable values) { - return createCriteria("?|", ArrayExpression.expressionOrWrap(values)); - } + @Override + public ExpressionTypeContext getType(EvaluationContext context) { + return source.getType(context); + } - @Override - public PgSql.PgCriteria jsonPathMatches(String jsonPath) { - return createCriteria("@?", new ValueExpression(jsonPath)); - } + @Override + public Expression evaluate(EvaluationContext context) { + return source.evaluate(context); + } - @Override - public PgSql.PgCriteria jsonPath(String jsonPath) { - return createCriteria("@@", new ValueExpression(jsonPath)); - } - }; + @Nullable + @Override + public String getNameHint() { + return source.getNameHint(); + } + + @Override + public String toString() { + return source.toString(); } } @@ -143,6 +150,10 @@ public Expression evaluate(EvaluationContext context) { return Comparison.create(lhs, operator, rhs); } + @Override + public String toString() { + return lhs() + " " + operator() + " " + rhs(); + } } record ValueExpression(Object value) implements QueryExpression { @@ -151,13 +162,18 @@ record ValueExpression(Object value) implements QueryExpression { public Expression evaluate(EvaluationContext context) { return context.bind(value); } + + @Override + public String toString() { + return ObjectUtils.nullSafeToString(value); + } } static class ValuesExpression implements QueryExpression { - private final Iterable values; + private final Collection values; - public ValuesExpression(Iterable values) { + public ValuesExpression(Collection values) { this.values = values; } @@ -180,15 +196,30 @@ public Expression evaluate(EvaluationContext context) { return TupleExpression.create(bindMarkers); } + + @Override + public String toString() { + return "(" + StringUtils.collectionToDelimitedString(values, ",") + ")"; + } } - record FunctionExpression(String function, Iterable values) implements PgSql.PostgresQueryExpression { + record FunctionExpression(String function, Collection values) implements PgSql.PostgresQueryExpression { @Override public Expression evaluate(EvaluationContext context) { return SimpleFunction.create(function, createArgumentExpressions(values(), context)); } + @Override + public String getNameHint() { + return function; + } + + @Override + public String toString() { + return function + "(" + StringUtils.collectionToDelimitedString(values, ",") + ")"; + } + private static List createArgumentExpressions(Iterable values, EvaluationContext context) { List arguments = new ArrayList<>(); @@ -205,13 +236,13 @@ private static List createArgumentExpressions(Iterable value } } - record ArrayExpression(Iterable values) implements QueryExpression { + record ArrayExpression(Collection values) implements QueryExpression { public static QueryExpression expressionOrWrap(Object[] values) { return expressionOrWrap(Arrays.asList(values)); } - public static QueryExpression expressionOrWrap(Iterable values) { + public static QueryExpression expressionOrWrap(Collection values) { Iterator iterator = values.iterator(); if (iterator.hasNext()) { @@ -234,6 +265,12 @@ public ExpressionTypeContext getType(EvaluationContext context) { public Expression evaluate(EvaluationContext context) { return BaseFunction.create("array", "[", "]", FunctionExpression.createArgumentExpressions(values(), context)); } + + @NonNull + @Override + public String toString() { + return "array[" + StringUtils.collectionToDelimitedString(values, ",") + "]"; + } } record JsonExpression(Map jsonObject, String type) implements QueryExpression { @@ -297,6 +334,19 @@ public Expression evaluate(EvaluationContext context) { return OperatorExpression.create(source.evaluate(context), operator, context.withType(type).bind(vector)); } + @Nullable + @Override + public String getNameHint() { + return source.getNameHint(); + } + + @Override + public String toString() { + + String vector = ObjectUtils.nullSafeToString(vector().getSource()).replace("{", "[").replace("}", "]"); + return source() + " " + getOperator() + " '" + vector + "'"; + } + private String getOperator() { if (scoringFunction == ScoringFunction.cosine()) { @@ -311,19 +361,8 @@ private String getOperator() { } } - static class ArrayOperator implements PgSql.PostgresQueryExpression { - - private final QueryExpression lhs; - private final String operator; - private final QueryExpression rhs; - private final TypeFunction typeFunction; - - public ArrayOperator(QueryExpression lhs, String operator, QueryExpression rhs, TypeFunction typeFunction) { - this.lhs = lhs; - this.operator = operator; - this.rhs = rhs; - this.typeFunction = typeFunction; - } + record ArrayOperator(QueryExpression lhs, String operator, QueryExpression rhs, + TypeFunction typeFunction) implements PgSql.PostgresQueryExpression { static TypeFunction just(ExpressionTypeContext type) { return (ex, context) -> type; @@ -348,6 +387,60 @@ public Expression evaluate(EvaluationContext context) { return OperatorExpression.create(lhs.evaluate(context.withType(rhs)), operator, rhs.evaluate(context.withType(lhs))); } + + @Override + public String toString() { + return lhs() + " " + operator() + " " + rhs(); + } + } + + static class DefaultPostgresJsonQueryExpression implements PostgresJsonQueryExpression { + + private final QueryExpression source; + + public DefaultPostgresJsonQueryExpression(QueryExpression source) { + this.source = source; + } + + public static PostgresJsonQueryExpression get(QueryExpression expression) { + return expression instanceof PostgresJsonQueryExpression jqe ? jqe + : new DefaultPostgresJsonQueryExpression(expression); + } + + @Override + public PgSql.PostgresQueryExpression exists(QueryExpression expression) { + return createCriteria("?", expression); + } + + @Override + public PgSql.PostgresQueryExpression contains(Object value) { + return createCriteria("@>", value); + } + + @Override + public PgSql.PostgresQueryExpression containsAll(Collection values) { + return createCriteria("?&", ArrayExpression.expressionOrWrap(values)); + } + + @Override + public PgSql.PostgresQueryExpression containsAny(Collection values) { + return createCriteria("?|", ArrayExpression.expressionOrWrap(values)); + } + + @Override + public PgSql.PostgresQueryExpression jsonPathMatches(String jsonPath) { + return createCriteria("@?", new ValueExpression(jsonPath)); + } + + @Override + public PgSql.PostgresQueryExpression jsonPath(String jsonPath) { + return createCriteria("@@", new ValueExpression(jsonPath)); + } + + private PgSql.PostgresQueryExpression createCriteria(String operator, Object value) { + return new PostgresComparison(source, operator, + value instanceof QueryExpression e ? e : new ValueExpression(value)); + } } static class JsonIndexOperator implements PgSql.PostgresQueryExpression { @@ -377,13 +470,13 @@ public PgSql.PostgresQueryExpression asString() { } @Override - public PgSql.PostgresQueryExpression asJson() { - return this; + public PostgresJsonQueryExpression asJson() { + return DefaultPostgresJsonQueryExpression.get(this); } @Override - public PgSql.PostgresQueryExpression asJsonb() { - return this; + public PostgresJsonQueryExpression asJsonb() { + return DefaultPostgresJsonQueryExpression.get(this); } @Override @@ -398,9 +491,26 @@ public ExpressionTypeContext getType(EvaluationContext context) { @Override public Expression evaluate(EvaluationContext context) { - return OperatorExpression.create(source.evaluate(context), baseOperator + (asString ? ">>" : ">"), + return OperatorExpression.create(source.evaluate(context), getOperator(), context.bind(keyOrIndex)); } + + @Nullable + @Override + public String getNameHint() { + return source.getNameHint(); + } + + @Override + public String toString() { + return source + " " + getOperator() + " " + + (keyOrIndex instanceof Number ? keyOrIndex.toString() : "'" + keyOrIndex + "'"); + } + + private String getOperator() { + return baseOperator + (asString ? ">>" : ">"); + } + } static class TypeCast { @@ -428,10 +538,21 @@ public ExpressionTypeContext getType(EvaluationContext context) { return typeFunction.getType(source, context); } + @Nullable + @Override + public String getNameHint() { + return source.getNameHint(); + } + @Override public Expression evaluate(EvaluationContext context) { return new PostfixExpression(source.evaluate(context), Expressions.just(typeCast)); } + + @Override + public String toString() { + return source() + typeCast; + } } record ArrayIndexPostgresExpression(QueryExpression source, diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/query/QueryExpression.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/query/QueryExpression.java index 32e57d066e..bf9006c63c 100644 --- a/spring-data-relational/src/main/java/org/springframework/data/relational/core/query/QueryExpression.java +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/query/QueryExpression.java @@ -78,6 +78,10 @@ default ExpressionTypeContext getType(EvaluationContext context) { return ExpressionTypeContext.object(); } + default @Nullable String getNameHint() { + return null; + } + /** * Evaluates this query expression into a SQL {@link Expression} that can be used in a query. *

diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/BindMarker.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/BindMarker.java index 12e81143bd..09d1fc634e 100644 --- a/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/BindMarker.java +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/BindMarker.java @@ -23,7 +23,7 @@ * @author Mark Paluch * @since 1.1 */ -public class BindMarker extends AbstractSegment implements Expression { +public abstract class BindMarker extends AbstractSegment implements Expression { public static BindMarker named(String name) { return new NamedBindMarker(name); @@ -37,6 +37,8 @@ public static BindMarker indexed(String name, int index) { return new IndexedBindMarker(name, index); } + public abstract String getPlaceholder(); + @Override public String toString() { return "?"; @@ -55,6 +57,11 @@ public SqlIdentifier getName() { return SqlIdentifier.unquoted(name); } + @Override + public String getPlaceholder() { + return name; + } + @Override public boolean equals(Object o) { if (!(o instanceof NamedBindMarker that)) { @@ -92,6 +99,11 @@ public SqlIdentifier getName() { return SqlIdentifier.unquoted(name); } + @Override + public String getPlaceholder() { + return name; + } + @Override public boolean equals(Object o) { if (!(o instanceof IndexedBindMarker that)) { diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/Comparison.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/Comparison.java index 5e04c7d889..cf36dcc593 100644 --- a/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/Comparison.java +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/Comparison.java @@ -29,17 +29,8 @@ */ public class Comparison extends OperatorExpression implements Condition { - private final Expression left; - private final String comparator; - private final Expression right; - private Comparison(Expression left, String comparator, Expression right) { - super(left, comparator, right); - - this.left = left; - this.comparator = comparator; - this.right = right; } /** @@ -83,12 +74,12 @@ public static Comparison create(String unqualifiedColumnName, String comparator, @Override public Condition not() { - if ("=".equals(comparator)) { - return new Comparison(left, "!=", right); + if ("=".equals(getOperator())) { + return new Comparison(getLeft(), "!=", getRight()); } - if ("!=".equals(comparator)) { - return new Comparison(left, "=", right); + if ("!=".equals(getOperator())) { + return new Comparison(getLeft(), "=", getRight()); } return new Not(this); diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/Conditions.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/Conditions.java index 2013e04450..9d9d2d5bd3 100644 --- a/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/Conditions.java +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/Conditions.java @@ -325,6 +325,18 @@ public static In notIn(Column column, Select subselect) { return notIn(column, new SubselectExpression(subselect)); } + /** + * Returns a {@link Condition} from an {@link Expression}. If the expression is already a {@link Condition}, it is + * just returned, otherwise returned as {@link AndCondition}. + * + * @param expression the expression to be returned as condition. + * @return the condition from the given {@link Expression}. + * @since 4.0 + */ + public static Condition from(Expression expression) { + return expression instanceof Condition c ? c : new AndCondition(expression); + } + // Utility constructor. private Conditions() {} diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/SQL.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/SQL.java index 22dabaf31f..7d7c9db80c 100644 --- a/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/SQL.java +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/SQL.java @@ -25,7 +25,7 @@ *

* The Statement Builder API is intended for framework usage to produce SQL required for framework operations. *

- * + * * @author Mark Paluch * @author Jens Schauder * @since 1.1 @@ -63,7 +63,7 @@ public static Table table(String name) { * @return a new {@link BindMarker}. */ public static BindMarker bindMarker() { - return new BindMarker(); + return bindMarker("?"); } /** diff --git a/spring-data-relational/src/test/java/org/springframework/data/relational/core/query/PgSqlUnitTests.java b/spring-data-relational/src/test/java/org/springframework/data/relational/core/query/PgSqlUnitTests.java index 317a5b815c..49e7a53ae7 100644 --- a/spring-data-relational/src/test/java/org/springframework/data/relational/core/query/PgSqlUnitTests.java +++ b/spring-data-relational/src/test/java/org/springframework/data/relational/core/query/PgSqlUnitTests.java @@ -43,12 +43,12 @@ class PgSqlUnitTests { MappingRelationalConverter converter = new MappingRelationalConverter(context); Table table = Table.create("with_embedding"); SimpleBindings bindings = new SimpleBindings(); - SimpleEvaluationContext renderContext = new SimpleEvaluationContext(table, BindMarkersFactory.named(":", "p", 32), - bindings, - converter, context.getRequiredPersistentEntity(WithEmbedding.class)); + SimpleEvaluationContext renderContext = new SimpleEvaluationContext(table, + BindMarkersFactory.named(":", "p", 32).create(), bindings, converter, + context.getRequiredPersistentEntity(WithEmbedding.class)); - @Test - void distanceLessThan() { + @Test // GH-1953 + void distanceLessThanTransformFunction() { // where(distanceOf("embedding", vector).l2()).lessThan(…) @@ -58,11 +58,36 @@ void distanceLessThan() { String sql = toSql(embedding); + assertThat(embedding).hasToString("embedding <=> '[1.0, 2.0, 3.0]' < 0.8"); assertThat(sql).contains("with_embedding.the_embedding <=> :p0 < :p1"); assertThat(bindings.getValues()).containsValue(new BigDecimal("0.8")); } - @Test + @Test // GH-1953 + void distanceLessThanDistanceStep() { + + Criteria embedding = PgSql.where(PgSql.vectorSearch().distanceOf("embedding", Vector.of(1, 2, 3)).cosine()) + .lessThan("0.8"); // converter converts to Number + + String sql = toSql(embedding); + + assertThat(embedding).hasToString("embedding <=> '[1.0, 2.0, 3.0]' < 0.8"); + assertThat(sql).contains("with_embedding.the_embedding <=> :p0 < :p1"); + assertThat(bindings.getValues()).containsValue(new BigDecimal("0.8")); + } + + @Test // GH-1953 + void castFieldToBoolean() { + + Criteria booleanJsonFieldIsTrue = PgSql.where("properties", it -> it.field("active")).asBoolean(); + + String sql = toSql(booleanJsonFieldIsTrue); + + assertThat(booleanJsonFieldIsTrue).hasToString("(properties -> 'active')::boolean"); + assertThat(sql).contains("(with_embedding.properties -> :p0)::boolean"); + } + + @Test // GH-1953 void fieldIsActiveIsTrue() { Criteria booleanJsonFieldIsTrue = PgSql.where("properties", it -> it.field("active").asBoolean()).isTrue(); @@ -72,18 +97,17 @@ void fieldIsActiveIsTrue() { assertThat(sql).contains("(with_embedding.properties -> :p0)::boolean = :p1"); } - @Test + @Test // GH-1953 void jsonContainsAll() { - Criteria jsonContains1 = PgSql.where("tags").json().containsAll("electronics", "gaming"); - Criteria jsonContains2 = PgSql.where("tags").json(it -> it.containsAll("electronics", "gaming")); + Criteria jsonContains = PgSql.where("tags").json(it -> it.containsAll("electronics", "gaming")); - String sql = toSql(arrayContains); + String sql = toSql(jsonContains); assertThat(sql).contains("with_embedding.tags ?& array[:p0, :p1]"); } - @Test + @Test // GH-1953 void toJsonEquals() { QueryExpression json = PgSql.json().jsonOf(Map.of("foo", "bar")); @@ -94,7 +118,7 @@ void toJsonEquals() { assertThat(sql).contains(":p0::json -> :p1 = :p2"); } - @Test + @Test // GH-1953 void arrayOverlapsColumn() { Criteria arrayContains = PgSql.where("tags").arrays().overlaps("country"); @@ -104,7 +128,7 @@ void arrayOverlapsColumn() { assertThat(sql).contains("with_embedding.tags && with_embedding.country"); } - @Test + @Test // GH-1953 void arrayConcatColumnsAndContains() { Criteria arrayContains = PgSql.where("tags", it -> it.array().concatWith("other_tags")).arrays().contains("1", "2", @@ -115,7 +139,7 @@ void arrayConcatColumnsAndContains() { assertThat(sql).contains("with_embedding.tags || with_embedding.other_tags @> array[:p0, :p1, :p2]"); } - @Test + @Test // GH-1953 void shouldRenderSourceFunction() { // SqlIdentifier to refer to columns? @@ -127,7 +151,7 @@ void shouldRenderSourceFunction() { assertThat(sql).contains("array_ndims(with_embedding.some_array) > :p0"); } - @Test + @Test // GH-1953 void arrayContains() { Criteria someArrayContains = PgSql.where("someArray").arrays() diff --git a/spring-data-relational/src/test/java/org/springframework/data/relational/core/query/SimpleEvaluationContext.java b/spring-data-relational/src/test/java/org/springframework/data/relational/core/query/SimpleEvaluationContext.java new file mode 100644 index 0000000000..fbe86521c1 --- /dev/null +++ b/spring-data-relational/src/test/java/org/springframework/data/relational/core/query/SimpleEvaluationContext.java @@ -0,0 +1,79 @@ +/* + * Copyright 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 org.springframework.data.relational.core.binding.BindMarkers; +import org.springframework.data.relational.core.conversion.RelationalConverter; +import org.springframework.data.relational.core.mapping.RelationalPersistentEntity; +import org.springframework.data.relational.core.sql.BindMarker; +import org.springframework.data.relational.core.sql.Table; + +/** + * @author Mark Paluch + */ +public class SimpleEvaluationContext extends EvaluationContextSupport { + + private final BindMarkers bindMarkers; + private final Bindings bindings; + private final QueryExpression.ExpressionTypeContext type; + + public SimpleEvaluationContext(Table table, BindMarkers bindMarkers, Bindings bindings, RelationalConverter converter, + RelationalPersistentEntity entity) { + + super(table, converter, entity); + this.bindMarkers = bindMarkers; + this.bindings = bindings; + this.type = QueryExpression.ExpressionTypeContext.object(); + + } + + public SimpleEvaluationContext(SimpleEvaluationContext previous, QueryExpression.ExpressionTypeContext type) { + super(previous); + this.bindMarkers = previous.bindMarkers; + this.bindings = previous.bindings; + this.type = type; + } + + public Bindings getBindings() { + return bindings; + } + + @Override + public QueryExpression.EvaluationContext withType(QueryExpression.ExpressionTypeContext type) { + return new SimpleEvaluationContext(this, type); + } + + @Override + public BindMarker bind(Object value) { + + BindMarker bindMarker = bindMarkers.next(); + + bindings.bind(bindMarker, getConverter().writeValue(value, type.getTargetType())); + + return bindMarker; + } + + @Override + public BindMarker bind(String name, Object value) { + + BindMarker bindMarker = bindMarkers.next(name); + + bindings.bind(bindMarker, getConverter().writeValue(value, type.getTargetType())); + + return bindMarker; + } + +} From c6ebdd8296fe281bb1149f335d63c8880c2aa8ff Mon Sep 17 00:00:00 2001 From: Mark Paluch Date: Thu, 26 Jun 2025 16:42:54 +0200 Subject: [PATCH 8/8] Return consistently PgCriteria --- .../data/relational/core/query/PgSql.java | 68 ++++++++++++++ .../data/relational/core/query/PgSqlImpl.java | 90 +++++++++++++++++++ .../core/query/QueryExpression.java | 6 ++ .../relational/core/query/PgSqlUnitTests.java | 8 +- 4 files changed, 170 insertions(+), 2 deletions(-) diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/query/PgSql.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/query/PgSql.java index d17051f10e..c16e83566f 100644 --- a/spring-data-relational/src/main/java/org/springframework/data/relational/core/query/PgSql.java +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/query/PgSql.java @@ -237,6 +237,13 @@ public PgCriteria.PostgresCriteriaStep and(String column) { return and(QueryExpression.column(column)); } + public PgCriteria.PostgresCriteriaStep and(String column, Function wrappingFunction) { + + Assert.hasText(column, "Column name must not be null or empty"); + + return and(wrappingFunction.apply(new PgSqlImpl.DefaultOperators(QueryExpression.column(column)))); + } + @Override public PgCriteria.PostgresCriteriaStep and(QueryExpression expression) { @@ -253,6 +260,13 @@ public PgCriteria.PostgresCriteriaStep or(String column) { return or(QueryExpression.column(column)); } + public PgCriteria.PostgresCriteriaStep or(String column, Function wrappingFunction) { + + Assert.hasText(column, "Column name must not be null or empty"); + + return or(wrappingFunction.apply(new PgSqlImpl.DefaultOperators(QueryExpression.column(column)))); + } + @Override public PgCriteria.PostgresCriteriaStep or(QueryExpression expression) { @@ -302,6 +316,60 @@ public interface PostgresCriteriaStep extends CriteriaStep { * @return */ PgCriteria asBoolean(); + + @Override + PgCriteria is(Object value); + + @Override + PgCriteria not(Object value); + + @Override + PgCriteria in(Object... values); + + @Override + PgCriteria in(Collection values); + + @Override + PgCriteria notIn(Object... values); + + @Override + PgCriteria notIn(Collection values); + + @Override + PgCriteria between(Object begin, Object end); + + @Override + PgCriteria notBetween(Object begin, Object end); + + @Override + PgCriteria lessThan(Object value); + + @Override + PgCriteria lessThanOrEquals(Object value); + + @Override + PgCriteria greaterThan(Object value); + + @Override + PgCriteria greaterThanOrEquals(Object value); + + @Override + PgCriteria like(Object value); + + @Override + PgCriteria notLike(Object value); + + @Override + PgCriteria isNull(); + + @Override + PgCriteria isNotNull(); + + @Override + PgCriteria isTrue(); + + @Override + PgCriteria isFalse(); } /** diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/query/PgSqlImpl.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/query/PgSqlImpl.java index 3d7b198c26..fb6c34720c 100644 --- a/spring-data-relational/src/main/java/org/springframework/data/relational/core/query/PgSqlImpl.java +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/query/PgSqlImpl.java @@ -103,6 +103,96 @@ public PgSql.PgCriteria json(Function values) { + return (PgSql.PgCriteria) super.in(values); + } + + @Override + public PgSql.PgCriteria notIn(Object... values) { + return (PgSql.PgCriteria) super.notIn(values); + } + + @Override + public PgSql.PgCriteria notIn(Collection values) { + return (PgSql.PgCriteria) super.notIn(values); + } + + @Override + public PgSql.PgCriteria between(Object begin, Object end) { + return (PgSql.PgCriteria) super.between(begin, end); + } + + @Override + public PgSql.PgCriteria notBetween(Object begin, Object end) { + return (PgSql.PgCriteria) super.notBetween(begin, end); + } + + @Override + public PgSql.PgCriteria lessThan(Object value) { + return (PgSql.PgCriteria) super.lessThan(value); + } + + @Override + public PgSql.PgCriteria lessThanOrEquals(Object value) { + return (PgSql.PgCriteria) super.lessThanOrEquals(value); + } + + @Override + public PgSql.PgCriteria greaterThan(Object value) { + return (PgSql.PgCriteria) super.greaterThan(value); + } + + @Override + public PgSql.PgCriteria greaterThanOrEquals(Object value) { + return (PgSql.PgCriteria) super.greaterThanOrEquals(value); + } + + @Override + public PgSql.PgCriteria like(Object value) { + return (PgSql.PgCriteria) super.like(value); + } + + @Override + public PgSql.PgCriteria notLike(Object value) { + return (PgSql.PgCriteria) super.notLike(value); + } + + @Override + public PgSql.PgCriteria isNull() { + return (PgSql.PgCriteria) super.isNull(); + } + + @Override + public PgSql.PgCriteria isNotNull() { + return (PgSql.PgCriteria) super.isNotNull(); + } + + @Override + public PgSql.PgCriteria isTrue() { + return (PgSql.PgCriteria) super.isTrue(); + } + + @Override + public PgSql.PgCriteria isFalse() { + return (PgSql.PgCriteria) super.isFalse(); + } } private record DefaultPostgresQueryExpression(QueryExpression source) implements PgSql.PostgresQueryExpression { diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/query/QueryExpression.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/query/QueryExpression.java index bf9006c63c..906e96409b 100644 --- a/spring-data-relational/src/main/java/org/springframework/data/relational/core/query/QueryExpression.java +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/query/QueryExpression.java @@ -78,6 +78,12 @@ default ExpressionTypeContext getType(EvaluationContext context) { return ExpressionTypeContext.object(); } + /** + * Name hint for this expression. The hint can be a function or column name. Hints are used to provide more detail + * context for e.g. bind markers. + * + * @return the name hint if applicable, or {@literal null} if no name hint is available. + */ default @Nullable String getNameHint() { return null; } diff --git a/spring-data-relational/src/test/java/org/springframework/data/relational/core/query/PgSqlUnitTests.java b/spring-data-relational/src/test/java/org/springframework/data/relational/core/query/PgSqlUnitTests.java index 49e7a53ae7..0f62f15e91 100644 --- a/spring-data-relational/src/test/java/org/springframework/data/relational/core/query/PgSqlUnitTests.java +++ b/spring-data-relational/src/test/java/org/springframework/data/relational/core/query/PgSqlUnitTests.java @@ -50,14 +50,18 @@ class PgSqlUnitTests { @Test // GH-1953 void distanceLessThanTransformFunction() { + // TODO // where(distanceOf("embedding", vector).l2()).lessThan(…) - Criteria embedding = PgSql + PgSql.PgCriteria embedding = PgSql .where("embedding", it -> it.distanceTo(Vector.of(1, 2, 3), PgSql.VectorSearchOperators.Distances::cosine)) - .lessThan("0.8"); // converter converts to Number + .lessThan("0.8"); + // .and("some_json", it -> it.json().field("country")).is("Some Country"); String sql = toSql(embedding); + // assertThat(embedding).hasToString("embedding <=> '[1.0, 2.0, 3.0]' < 0.8 AND some_json -> 'country' = 'Some + // Country'"); assertThat(embedding).hasToString("embedding <=> '[1.0, 2.0, 3.0]' < 0.8"); assertThat(sql).contains("with_embedding.the_embedding <=> :p0 < :p1"); assertThat(bindings.getValues()).containsValue(new BigDecimal("0.8"));