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-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-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-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/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 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..81d0759fd1 --- /dev/null +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/query/Bindings.java @@ -0,0 +1,29 @@ +/* + * 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; + +/** + * @author Mark Paluch + */ +public interface Bindings { + + 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 2b2deff2f2..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 @@ -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,39 @@ 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); } - private Criteria(@Nullable Criteria previous, Combinator combinator, List group, - @Nullable SqlIdentifier column, @Nullable Comparator comparator, @Nullable Object value) { - this(previous, combinator, group, column, comparator, value, false); + protected Criteria(QueryExpression queryExpression, @Nullable 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, - @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) { + this(previous, combinator, group, queryExpression, column, comparator, value, false); + } + + 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 +107,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; @@ -153,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(QueryExpression.column(column), SqlIdentifier.unquoted(column)); } /** @@ -167,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) { + QueryExpression lhs = QueryExpression.column(column); + return new DefaultCriteriaStep(lhs, identifier) { + @Override + protected Criteria createCriteria(Comparator comparator, @Nullable Object 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(), identifier, comparator, value); + return new Criteria(Criteria.this, Combinator.AND, Collections.emptyList(), expression, null, comparator, + value); } }; } @@ -214,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) { + QueryExpression lhs = QueryExpression.column(column); + return new DefaultCriteriaStep(lhs, identifier) { @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(), 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(), expression, null, comparator, value); } }; } @@ -259,7 +314,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; } @@ -315,6 +370,10 @@ private boolean doIsEmpty() { return false; } + if (this.queryExpression != null) { + return false; + } + for (CriteriaDefinition criteria : group) { if (!criteria.isEmpty()) { @@ -344,6 +403,12 @@ public List getGroup() { return group; } + @Nullable + @Override + public QueryExpression getExpression() { + return queryExpression; + } + /** * @return the column/property name. */ @@ -476,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()) { @@ -502,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) { @@ -658,11 +742,18 @@ public interface CriteriaStep { /** * Default {@link CriteriaStep} implementation. */ - static class DefaultCriteriaStep implements CriteriaStep { + protected static class DefaultCriteriaStep implements CriteriaStep { + + private final QueryExpression lhs; + private final @Nullable SqlIdentifier property; - private final SqlIdentifier property; + protected DefaultCriteriaStep(QueryExpression lhs) { + this.lhs = lhs; + this.property = null; + } - DefaultCriteriaStep(SqlIdentifier property) { + DefaultCriteriaStep(QueryExpression lhs, SqlIdentifier property) { + this.lhs = lhs; this.property = property; } @@ -734,7 +825,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 @@ -812,8 +903,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/CriteriaDefinition.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/query/CriteriaDefinition.java index c09129a1b6..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,12 +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 new file mode 100644 index 0000000000..4c0328a073 --- /dev/null +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/query/CriteriaSources.java @@ -0,0 +1,88 @@ +/* + * 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.IdentifierProcessing; +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 { + + /** + * A column name (or property path) reference. + * + * @param name + */ + public record Column(String name) implements QueryExpression { + + @Override + 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; + } + } + + /** + * A column or alias name. + * + * @param identifier + */ + public record SqlIdentifierSource(SqlIdentifier identifier) implements QueryExpression { + + @Override + 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/EvaluationContextSupport.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/query/EvaluationContextSupport.java new file mode 100644 index 0000000000..d27501f7a1 --- /dev/null +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/query/EvaluationContextSupport.java @@ -0,0 +1,285 @@ +/* + * 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.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.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; +import org.springframework.util.ClassUtils; + +/** + * Support class for {@link QueryExpression.EvaluationContext} implementations. + * + * @author Mark Paluch + * @since 4.0 + */ +public abstract class EvaluationContextSupport implements QueryExpression.EvaluationContext { + + private final Table table; + private final RelationalConverter converter; + private final @Nullable RelationalPersistentEntity entity; + + public EvaluationContextSupport(Table table, RelationalConverter converter, + @Nullable RelationalPersistentEntity entity) { + + this.table = table; + this.converter = converter; + this.entity = entity; + } + + protected EvaluationContextSupport(EvaluationContextSupport previous) { + + this.table = previous.table; + this.converter = previous.converter; + this.entity = previous.entity; + } + + protected RelationalConverter getConverter() { + return converter; + } + + private Field createPropertyField(SqlIdentifier key) { + 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); + } + + // TODO: Some tables can come from a JOIN, see JDBC + @Override + 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); + } + + protected TableLike getTable(SqlIdentifier identifier) { + return table; + } + + protected TableLike getTable(String column) { + return table; + } + + @Override + public abstract QueryExpression.EvaluationContext withType(QueryExpression.ExpressionTypeContext type); + + static class DefaultMappedColumn implements QueryExpression.MappedColumn { + + 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(); + } + + } + + /** + * 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; + } + + public @Nullable RelationalPersistentProperty getProperty() { + return null; + } + } + + /** + * 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(); + } + + @Nullable + @Override + public RelationalPersistentProperty getProperty() { + return this.property; + } + } +} 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..1d59aa87f6 --- /dev/null +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/query/NestedQueryExpression.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.query; + +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 + * + * @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 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 new file mode 100644 index 0000000000..c16e83566f --- /dev/null +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/query/PgSql.java @@ -0,0 +1,1003 @@ +/* + * 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.Collection; +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; + +/** + * Dialect-specific extension for the Postgres database dialect. + * + * @author Mark Paluch + * @since 4.0 + */ +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 PgCriteria.where(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) { + return PgCriteria.where(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 PgCriteria.where(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) { + return PgCriteria.where(expression, wrappingFunction); + } + + /** + * Entrypoint for array functions. + * + * @return + */ + public static ArrayFunctions arrays() { + + return new ArrayFunctions() { + @Override + public QueryExpression arrayOf(Object... values) { + return new PgSqlImpl.ArrayExpression(Arrays.asList(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"); + } + }; + } + + 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. + */ + public static class PgCriteria extends Criteria { + + 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); + } + + /** + * 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) { + + 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) { + + Assert.hasText(column, "Column name must not be null or empty"); + + 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) { + + 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(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) { + + 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 { + + /** + * Array criteria steps. + * + * @return + */ + PostgresArrayCriteriaStep arrays(); + + /** + * JSON criteria steps. + * + * @return + */ + PgCriteria json(Function criteriaFunction); + + /** + * Consider the previous expression as expression evaluating a boolean result. + * + * @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(); + } + + /** + * Fluent Postgres-specific Array criteria API providing access to Array operators and functions. + */ + public interface PostgresArrayCriteriaStep { + + /** + * 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 + */ + PgCriteria 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 PgCriteria contains(String column) { + return contains(QueryExpression.column(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(QueryExpression.column(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)); + } + + } + + /** + * Fluent Postgres-specific JSON criteria API providing access to JSON operators and functions. + */ + public interface PostgresJsonCriteriaStep { + + /** + * {@code ?} operator for JSON exists. + * + * @param column + * @return + */ + default PgCriteria exists(String column) { + return exists(QueryExpression.column(column)); + } + + /** + * {@code ?} operator for JSON exists. + * + * @param expression + * @return + */ + PgCriteria exists(QueryExpression expression); + + /** + * {@code ?} operator for JSON exists. + * + * @param value + * @return + */ + 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)); + } + + /** + * {@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)); + } + + /** + * {@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); + + } + } + + /** + * 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); + + } + + /** + * 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. + */ + 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 JsonOperators} object providing access to JSON functions. + */ + default JsonOperators json() { + return this; + } + + /** + * Returns a {@link VectorSearchOperators} object providing access to pgvector functions. + */ + default VectorSearchOperators vector() { + return this; + } + + } + + /** + * pgvector-specific functions for Postgres Vector Search. + */ + public interface VectorSearchOperators { + + /** + * 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 JSON functions for querying JSON data. + */ + public interface JsonOperators { + + /** + * 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 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(QueryExpression.column(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(QueryExpression.column(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(QueryExpression.column(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. + */ + 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 PgSqlImpl.PgCastExpression.create(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 PostgresJsonQueryExpression asJson() { + return (PostgresJsonQueryExpression) as("json"); + } + + /** + * Cast the expression to varchar type by appending {@code ::jsonb} the expression. + * + * @return the casted expression. + */ + default PostgresJsonQueryExpression asJsonb() { + return (PostgresJsonQueryExpression) as("jsonb"); + } + + default PostgresQueryExpression json(Function jsonFunction) { + return jsonFunction.apply((PostgresJsonQueryExpression) this); + } + + /** + * 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); + } + + } + + /** + * 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 new file mode 100644 index 0000000000..fb6c34720c --- /dev/null +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/query/PgSqlImpl.java @@ -0,0 +1,662 @@ +/* + * 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.Collection; +import java.util.Iterator; +import java.util.List; +import java.util.Locale; +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.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; +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.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. + * + * @author Mark Paluch + * @since 4.0 + */ +class PgSqlImpl { + + static class DefaultPostgresCriteriaStep extends Criteria.DefaultCriteriaStep + implements PgSql.PgCriteria.PostgresCriteriaStep { + + protected DefaultPostgresCriteriaStep(String propertyName) { + this(QueryExpression.column(propertyName)); + } + + protected DefaultPostgresCriteriaStep(QueryExpression source) { + super(source); + } + + protected Criteria createCriteria(CriteriaDefinition.Comparator comparator, @Nullable Object value) { + + return new PgSql.PgCriteria(new PostgresComparison(getLhs(), + (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(getLhs(), operator, + value instanceof QueryExpression e ? e : new ValueExpression(value))); + } + + protected PgSql.PgCriteria createCriteria(QueryExpression queryExpression) { + return new PgSql.PgCriteria(queryExpression); + } + + @Override + 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(QueryExpression expression) { + return createCriteria("&&", expression); + } + }; + } + + @Override + public PgSql.PgCriteria json(Function criteriaFunction) { + return createCriteria(criteriaFunction.apply(DefaultPostgresJsonQueryExpression.get(getLhs()))); + } + + @Override + public PgSql.PgCriteria asBoolean() { + return createCriteria(DefaultPostgresQueryExpression.queryExpression(getLhs()).asBoolean()); + } + + @Override + public PgSql.PgCriteria is(Object value) { + return (PgSql.PgCriteria) super.is(value); + } + + @Override + public PgSql.PgCriteria not(Object value) { + return (PgSql.PgCriteria) super.not(value); + } + + @Override + public PgSql.PgCriteria in(Object... values) { + return (PgSql.PgCriteria) super.in(values); + } + + @Override + public PgSql.PgCriteria in(Collection 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 { + + public static PgSql.PostgresQueryExpression queryExpression(QueryExpression source) { + return source instanceof PgSql.PostgresQueryExpression pq ? pq : new DefaultPostgresQueryExpression(source); + } + + @Override + public ExpressionTypeContext getType(EvaluationContext context) { + return source.getType(context); + } + + @Override + public Expression evaluate(EvaluationContext context) { + return source.evaluate(context); + } + + @Nullable + @Override + public String getNameHint() { + return source.getNameHint(); + } + + @Override + public String toString() { + return source.toString(); + } + } + + private record PostgresComparison(QueryExpression lhs, String operator, + QueryExpression rhs) implements PgSql.PostgresQueryExpression { + + @Override + public ExpressionTypeContext getType(EvaluationContext context) { + return ExpressionTypeContext.bool(); + } + + @Override + public Expression evaluate(EvaluationContext 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); + } + + @Override + public String toString() { + return lhs() + " " + operator() + " " + rhs(); + } + } + + record ValueExpression(Object value) implements QueryExpression { + + @Override + public Expression evaluate(EvaluationContext context) { + return context.bind(value); + } + + @Override + public String toString() { + return ObjectUtils.nullSafeToString(value); + } + } + + static class ValuesExpression implements QueryExpression { + + private final Collection values; + + public ValuesExpression(Collection 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 evaluate(EvaluationContext context) { + + List bindMarkers = new ArrayList<>(); + for (Object value : this.values) { + bindMarkers.add(context.bind(value)); + } + + return TupleExpression.create(bindMarkers); + } + + @Override + public String toString() { + return "(" + StringUtils.collectionToDelimitedString(values, ",") + ")"; + } + } + + 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<>(); + for (Object value : values) { + if (value == null) { + arguments.add(Expressions.just("NULL")); + } else if (value instanceof QueryExpression qe) { + arguments.add(qe.evaluate(context)); + } else { + arguments.add(context.bind(value)); + } + } + return arguments; + } + } + + record ArrayExpression(Collection values) implements QueryExpression { + + public static QueryExpression expressionOrWrap(Object[] values) { + return expressionOrWrap(Arrays.asList(values)); + } + + public static QueryExpression expressionOrWrap(Collection 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 ExpressionTypeContext getType(EvaluationContext context) { + return ExpressionTypeContext.object().asCollection(); + } + + @Override + 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 { + + @Override + public Expression evaluate(EvaluationContext 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, + ArrayOperator.just(QueryExpression.ExpressionTypeContext.bool())); + } + + @Override + public PgSql.PostgresQueryExpression overlaps(QueryExpression expression) { + return new ArrayOperator(source, "&&", expression, + ArrayOperator.just(QueryExpression.ExpressionTypeContext.bool())); + } + + @Override + public PgSql.PostgresQueryExpression concatWith(QueryExpression expression) { + return new ArrayOperator(source, "||", expression, QueryExpression::getType); + } + + @Override + public PgSql.PostgresQueryExpression index(int index) { + return new JsonIndexOperator(source, JsonIndexOperator.FIELD_OR_INDEX, index); + } + + @Override + public PgSql.PostgresQueryExpression field(String field) { + return new JsonIndexOperator(source, JsonIndexOperator.FIELD_OR_INDEX, field); + } + + @Override + public PgSql.PostgresQueryExpression distanceTo(Vector vector, + Function distanceFunction) { + return new DistanceFunction(source, distanceFunction.apply(new Distances() {}), vector); + } + } + + record DistanceFunction(QueryExpression source, ScoringFunction scoringFunction, + Vector vector) implements PgSql.PostgresQueryExpression { + + @Override + public ExpressionTypeContext getType(EvaluationContext context) { + return ExpressionTypeContext.of(Number.class); + } + + @Override + public Expression evaluate(EvaluationContext context) { + + String operator = getOperator(); + + ExpressionTypeContext type = source.getType(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()) { + return "<=>"; + } else if (scoringFunction == ScoringFunction.euclidean()) { + return "<->"; + } else if (scoringFunction == ScoringFunction.dotProduct()) { + return "<#>"; + } + + return scoringFunction.getName(); + } + } + + record ArrayOperator(QueryExpression lhs, String operator, QueryExpression rhs, + TypeFunction typeFunction) implements PgSql.PostgresQueryExpression { + + static TypeFunction just(ExpressionTypeContext type) { + return (ex, context) -> type; + } + + @Override + public PgSql.PostgresQueryExpression as(String type) { + return PgCastExpression.create(this.nest(), type); + } + + @Override + public ExpressionTypeContext getType(EvaluationContext context) { + + ExpressionTypeContext l = typeFunction.getType(lhs, context); + ExpressionTypeContext r = typeFunction.getType(rhs, context); + + return l.getAssignableType(r); + } + + @Override + 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 { + + public static final String FIELD_OR_INDEX = "-"; + public static final String PATH = "#"; + + private final QueryExpression source; + private final String baseOperator; + private final boolean asString; + private final Object keyOrIndex; + + public JsonIndexOperator(QueryExpression source, String baseOperator, Object keyOrIndex) { + this(source, baseOperator, false, keyOrIndex); + } + + public JsonIndexOperator(QueryExpression 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 JsonIndexOperator(this.source, this.baseOperator, true, this.keyOrIndex); + } + + @Override + public PostgresJsonQueryExpression asJson() { + return DefaultPostgresJsonQueryExpression.get(this); + } + + @Override + public PostgresJsonQueryExpression asJsonb() { + return DefaultPostgresJsonQueryExpression.get(this); + } + + @Override + public PgSql.PostgresQueryExpression as(String type) { + return new PgCastExpression(this.nest(), "::" + type, (it, ctx) -> TypeCast.getType(type)); + } + + @Override + public ExpressionTypeContext getType(EvaluationContext context) { + return asString ? ExpressionTypeContext.of(String.class) : ExpressionTypeContext.object(); + } + + @Override + public Expression evaluate(EvaluationContext context) { + 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 { + + 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 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, + Object keyOrIndex) implements PgSql.PostgresQueryExpression { + + @Override + 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 new file mode 100644 index 0000000000..906e96409b --- /dev/null +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/query/QueryExpression.java @@ -0,0 +1,263 @@ +/* + * 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.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. + *

+ * 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.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. + * + * @return this expression as nested expression. + */ + default QueryExpression nest() { + return new NestedQueryExpression(this); + } + + /** + * 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(); + } + + /** + * 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; + } + + /** + * 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 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 evaluate(EvaluationContext context); + + /** + * Interface to bind values to (i.e. during evaluation of a {@link QueryExpression}). + */ + interface BindableContext { + + // TODO: we should have a bind method that performs JSON serialization. + BindMarker bind(Object value); + + BindMarker bind(String name, Object value); + } + + /** + * 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 { + + /** + * @return the {@link Expression} representing this column. + */ + Expression toExpression(); + + } + + /** + * {@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)); + } + } + + /** + * 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(); + } + + default boolean hasProperty() { + return getProperty() != null; + } + } + + /** + * 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/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/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..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 @@ -15,13 +15,29 @@ */ package org.springframework.data.relational.core.sql; +import org.springframework.util.ObjectUtils; + /** * Bind marker/parameter placeholder used to construct prepared statements with parameter substitution. * * @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); + } + + public static BindMarker indexed(int index) { + return new IndexedBindMarker("" + index, index); + } + + public static BindMarker indexed(String name, int index) { + return new IndexedBindMarker(name, index); + } + + public abstract String getPlaceholder(); @Override public String toString() { @@ -41,9 +57,75 @@ public SqlIdentifier getName() { return SqlIdentifier.unquoted(name); } + @Override + public String getPlaceholder() { + return 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 String getPlaceholder() { + return 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..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 @@ -27,19 +27,10 @@ * @author Jens Schauder * @since 1.1 */ -public class Comparison extends AbstractSegment implements Condition { - - private final Expression left; - private final String comparator; - private final Expression right; +public class Comparison extends OperatorExpression implements Condition { private Comparison(Expression left, String comparator, Expression right) { - - super(left, right); - - this.left = left; - this.comparator = comparator; - this.right = right; + super(left, comparator, right); } /** @@ -83,40 +74,15 @@ 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); } - /** - * @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/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/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/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/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..0f62f15e91 --- /dev/null +++ b/spring-data-relational/src/test/java/org/springframework/data/relational/core/query/PgSqlUnitTests.java @@ -0,0 +1,182 @@ +/* + * 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 java.math.BigDecimal; +import java.util.Map; + +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"); + SimpleBindings bindings = new SimpleBindings(); + SimpleEvaluationContext renderContext = new SimpleEvaluationContext(table, + BindMarkersFactory.named(":", "p", 32).create(), bindings, converter, + context.getRequiredPersistentEntity(WithEmbedding.class)); + + @Test // GH-1953 + void distanceLessThanTransformFunction() { + + // TODO + // where(distanceOf("embedding", vector).l2()).lessThan(…) + + PgSql.PgCriteria embedding = PgSql + .where("embedding", it -> it.distanceTo(Vector.of(1, 2, 3), PgSql.VectorSearchOperators.Distances::cosine)) + .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")); + } + + @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(); + + String sql = toSql(booleanJsonFieldIsTrue); + + assertThat(sql).contains("(with_embedding.properties -> :p0)::boolean = :p1"); + } + + @Test // GH-1953 + void jsonContainsAll() { + + Criteria jsonContains = PgSql.where("tags").json(it -> it.containsAll("electronics", "gaming")); + + String sql = toSql(jsonContains); + + assertThat(sql).contains("with_embedding.tags ?& array[:p0, :p1]"); + } + + @Test // GH-1953 + 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 // GH-1953 + void arrayOverlapsColumn() { + + Criteria arrayContains = PgSql.where("tags").arrays().overlaps("country"); + + String sql = toSql(arrayContains); + + assertThat(sql).contains("with_embedding.tags && with_embedding.country"); + } + + @Test // GH-1953 + 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 // GH-1953 + void shouldRenderSourceFunction() { + + // SqlIdentifier to refer to columns? + Criteria arrayContains = PgSql.where(PgSql.function("array_ndims", QueryExpression.column("someArray"))) + .greaterThan(2); + + String sql = toSql(arrayContains); + + assertThat(sql).contains("array_ndims(with_embedding.some_array) > :p0"); + } + + @Test // GH-1953 + void arrayContains() { + + Criteria someArrayContains = PgSql.where("someArray").arrays() + .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) { + + Expression expression = criteria.getExpression().evaluate(renderContext); + return SqlRenderer.toString(Select.builder().select(expression).from(table).where(expression).build()); + } + + static class WithEmbedding { + + @Column("the_embedding") Vector embedding; + + String[] someArray; + + } +} diff --git a/spring-data-relational/src/test/java/org/springframework/data/relational/core/query/SimpleBindings.java b/spring-data-relational/src/test/java/org/springframework/data/relational/core/query/SimpleBindings.java new file mode 100644 index 0000000000..8bcef58779 --- /dev/null +++ b/spring-data-relational/src/test/java/org/springframework/data/relational/core/query/SimpleBindings.java @@ -0,0 +1,43 @@ +/* + * 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.LinkedHashMap; +import java.util.Map; + +import org.springframework.data.relational.core.sql.BindMarker; + +/** + * @author Mark Paluch + */ +class SimpleBindings implements Bindings { + + private final Map values = new LinkedHashMap<>(); + + @Override + public void bind(BindMarker bindMarker, Object value) { + values.put(bindMarker, value); + } + + @Override + public void bind(BindMarker bindMarker, Object value, QueryExpression.ExpressionTypeContext typeContext) { + values.put(bindMarker, value); + } + + public Map getValues() { + return values; + } +} 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; + } + +}