diff --git a/src/main/java/org/springframework/data/r2dbc/dialect/BindMarker.java b/src/main/java/org/springframework/data/r2dbc/dialect/BindMarker.java new file mode 100644 index 0000000..a6971bf --- /dev/null +++ b/src/main/java/org/springframework/data/r2dbc/dialect/BindMarker.java @@ -0,0 +1,42 @@ +package org.springframework.data.r2dbc.dialect; + +import io.r2dbc.spi.Statement; + +/** + * A bind marker represents a single bindable parameter within a query. Bind markers are dialect-specific and provide a + * {@link #getPlaceholder() placeholder} that is used in the actual query. + * + * @author Mark Paluch + * @see Statement#bind + * @see BindMarkers + * @see BindMarkersFactory + */ +public interface BindMarker { + + /** + * Returns the database-specific placeholder for a given substitution. + * + * @return the database-specific placeholder for a given substitution. + */ + String getPlaceholder(); + + /** + * Bind the given {@code value} to the {@link Statement} using the underlying binding strategy. + * + * @param statement the statement to bind the value to. + * @param value the actual value. Must not be {@literal null}. Use {@link #bindNull(Statement, Class)} for + * {@literal null} values. + * @see Statement#bind + */ + void bindValue(Statement statement, Object value); + + /** + * Bind a {@literal null} value to the {@link Statement} using the underlying binding strategy. + * + * @param statement the statement to bind the value to. + * @param valueType value type, must not be {@literal null}. + * @see Statement#bindNull + */ + + void bindNull(Statement statement, Class valueType); +} diff --git a/src/main/java/org/springframework/data/r2dbc/dialect/BindMarkers.java b/src/main/java/org/springframework/data/r2dbc/dialect/BindMarkers.java new file mode 100644 index 0000000..63bb95f --- /dev/null +++ b/src/main/java/org/springframework/data/r2dbc/dialect/BindMarkers.java @@ -0,0 +1,36 @@ +package org.springframework.data.r2dbc.dialect; + +/** + * 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 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/bind parameter names. + * + * @author Mark Paluch + * @see BindMarker + * @see BindMarkersFactory + * @see io.r2dbc.spi.Statement#bind + */ +@FunctionalInterface +public interface BindMarkers { + + /** + * Creates a new {@link BindMarker}. + * + * @return a new {@link BindMarker}. + */ + BindMarker next(); + + /** + * Creates a new {@link BindMarker} that accepts a {@code nameHint}. Implementations are allowed to consider/ignore + * the name hint to create more expressive bind markers. + * + * @param nameHint an optional name hint. + * @return a new {@link BindMarker}. + */ + default BindMarker next(String nameHint) { + return next(); + } +} diff --git a/src/main/java/org/springframework/data/r2dbc/dialect/BindMarkersFactory.java b/src/main/java/org/springframework/data/r2dbc/dialect/BindMarkersFactory.java new file mode 100644 index 0000000..4832225 --- /dev/null +++ b/src/main/java/org/springframework/data/r2dbc/dialect/BindMarkersFactory.java @@ -0,0 +1,53 @@ +package org.springframework.data.r2dbc.dialect; + +import org.springframework.util.Assert; + +/** + * This class creates new {@link BindMarkers} instances to bind parameter for a specific {@link io.r2dbc.spi.Statement}. + * + * @author Mark Paluch + * @see BindMarkers + * @see io.r2dbc.spi.Statement + */ +@FunctionalInterface +public interface BindMarkersFactory { + + /** + * Create a new {@link BindMarkers} instance. + * + * @return a new {@link BindMarkers} instance. + */ + BindMarkers create(); + + /** + * Create index-based {@link BindMarkers}. + * + * @param prefix bind parameter prefix. + * @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 named {@link BindMarkers}. Named bind markers can support {@link BindMarkers#next(String) name hints}. + * Typically, named markers use name hints. If no namehint is given, named bind markers use a counter to generate + * unique bind markers. + * + * @param prefix bind parameter prefix. + * @param indexPrefix prefix for bind markers that were created by incrementing a counter to generate a unique bind + * marker. + * @param nameLimit maximal length of parameter names when using name hints. + * @return a {@link BindMarkersFactory} using {@code prefix} and {@code beginWith}. + */ + static BindMarkersFactory named(String prefix, String indexPrefix, int nameLimit) { + + Assert.notNull(prefix, "Prefix must not be null!"); + Assert.notNull(indexPrefix, "Index prefix must not be null!"); + + return () -> new NamedBindMarkers(prefix, indexPrefix, nameLimit); + } +} diff --git a/src/main/java/org/springframework/data/r2dbc/dialect/IndexedBindMarkers.java b/src/main/java/org/springframework/data/r2dbc/dialect/IndexedBindMarkers.java new file mode 100644 index 0000000..d77c467 --- /dev/null +++ b/src/main/java/org/springframework/data/r2dbc/dialect/IndexedBindMarkers.java @@ -0,0 +1,87 @@ +package org.springframework.data.r2dbc.dialect; + +import io.r2dbc.spi.Statement; + +import java.util.concurrent.atomic.AtomicIntegerFieldUpdater; + +/** + * Index-based bind marker. 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 + */ +class IndexedBindMarkers implements BindMarkers { + + private static final AtomicIntegerFieldUpdater COUNTER_INCREMENTER = AtomicIntegerFieldUpdater + .newUpdater(org.springframework.data.r2dbc.dialect.IndexedBindMarkers.class, "counter"); + + // access via COUNTER_INCREMENTER + @SuppressWarnings("unused") private volatile int counter; + + private final String prefix; + + /** + * Creates a new {@link IndexedBindMarker} instance given {@code prefix} and {@code beginWith}. + * + * @param prefix bind parameter prefix. + * @param beginWith the first index to use. + */ + IndexedBindMarkers(String prefix, int beginWith) { + this.counter = beginWith; + this.prefix = prefix; + } + + /* + * (non-Javadoc) + * @see org.springframework.data.r2dbc.dialect.BindMarkers#next() + */ + @Override + public BindMarker next() { + + int index = COUNTER_INCREMENTER.getAndIncrement(this); + + return new IndexedBindMarker(prefix + "" + index, index); + } + + /** + * A single indexed bind marker. + */ + static class IndexedBindMarker implements BindMarker { + + private final String placeholder; + + private int index; + + IndexedBindMarker(String placeholder, int index) { + this.placeholder = placeholder; + this.index = index; + } + + /* + * (non-Javadoc) + * @see org.springframework.data.r2dbc.dialect.BindMarker#getPlaceholder() + */ + @Override + public String getPlaceholder() { + return placeholder; + } + + /* + * (non-Javadoc) + * @see org.springframework.data.r2dbc.dialect.BindMarker#bindValue(io.r2dbc.spi.Statement, java.lang.Object) + */ + @Override + public void bindValue(Statement statement, Object value) { + statement.bind(this.index, value); + } + + /* + * (non-Javadoc) + * @see org.springframework.data.r2dbc.dialect.BindMarker#bindNull(io.r2dbc.spi.Statement, java.lang.Class) + */ + @Override + public void bindNull(Statement statement, Class valueType) { + statement.bindNull(this.index, valueType); + } + } +} diff --git a/src/main/java/org/springframework/data/r2dbc/dialect/NamedBindMarkers.java b/src/main/java/org/springframework/data/r2dbc/dialect/NamedBindMarkers.java new file mode 100644 index 0000000..cf42c77 --- /dev/null +++ b/src/main/java/org/springframework/data/r2dbc/dialect/NamedBindMarkers.java @@ -0,0 +1,129 @@ +package org.springframework.data.r2dbc.dialect; + +import io.r2dbc.spi.Statement; + +import java.util.concurrent.atomic.AtomicIntegerFieldUpdater; + +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"); + + // access via COUNTER_INCREMENTER + @SuppressWarnings("unused") private volatile int counter; + + private final String prefix; + + private final String indexPrefix; + + private final int nameLimit; + + NamedBindMarkers(String prefix, String indexPrefix, int nameLimit) { + + this.prefix = prefix; + this.indexPrefix = indexPrefix; + this.nameLimit = nameLimit; + } + + @Override + public BindMarker next() { + + String name = nextName(); + + return new NamedBindMarker(prefix + name, name); + } + + @Override + public BindMarker next(String nameHint) { + + Assert.notNull(nameHint, "Name hint must not be null"); + + String name = nextName(); + + String filteredNameHint = filter(nameHint); + + if (!filteredNameHint.isEmpty()) { + name += "_" + filteredNameHint; + } + + if (name.length() > nameLimit) { + name = name.substring(0, nameLimit); + } + + return new NamedBindMarker(prefix + name, name); + } + + private String nextName() { + + int index = COUNTER_INCREMENTER.getAndIncrement(this); + return indexPrefix + index; + } + + private static String filter(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); + } + + } + + return builder.toString(); + } + + /** + * A single named bind marker. + */ + static class NamedBindMarker implements BindMarker { + + private final String placeholder; + + private final String identifier; + + NamedBindMarker(String placeholder, String identifier) { + + this.placeholder = placeholder; + this.identifier = identifier; + } + + /* + * (non-Javadoc) + * @see org.springframework.data.r2dbc.dialect.BindMarker#getPlaceholder() + */ + @Override + public String getPlaceholder() { + return this.placeholder; + } + + /* + * (non-Javadoc) + * @see org.springframework.data.r2dbc.dialect.BindMarker#bindValue(io.r2dbc.spi.Statement, java.lang.Object) + */ + @Override + public void bindValue(Statement statement, Object value) { + statement.bind(this.identifier, value); + } + + /* + * (non-Javadoc) + * @see org.springframework.data.r2dbc.dialect.BindMarker#bindNull(io.r2dbc.spi.Statement, java.lang.Class) + */ + @Override + public void bindNull(Statement statement, Class valueType) { + statement.bindNull(this.identifier, valueType); + } + } +} diff --git a/src/main/java/org/springframework/data/r2dbc/dialect/package-info.java b/src/main/java/org/springframework/data/r2dbc/dialect/package-info.java new file mode 100644 index 0000000..9643ac2 --- /dev/null +++ b/src/main/java/org/springframework/data/r2dbc/dialect/package-info.java @@ -0,0 +1,7 @@ +/** + * Dialects abstract the SQL dialect of the underlying database. + */ +@NonNullApi +package org.springframework.data.r2dbc.dialect; + +import org.springframework.lang.NonNullApi; diff --git a/src/test/java/org/springframework/data/r2dbc/dialect/IndexedBindMarkersUnitTests.java b/src/test/java/org/springframework/data/r2dbc/dialect/IndexedBindMarkersUnitTests.java new file mode 100644 index 0000000..338a152 --- /dev/null +++ b/src/test/java/org/springframework/data/r2dbc/dialect/IndexedBindMarkersUnitTests.java @@ -0,0 +1,68 @@ +package org.springframework.data.r2dbc.dialect; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +import io.r2dbc.spi.Statement; + +import org.junit.Test; + +/** + * Unit tests for {@link IndexedBindMarkers}. + * + * @author Mark Paluch + */ +public class IndexedBindMarkersUnitTests { + + @Test // gh-15 + public void shouldCreateNewBindMarkers() { + + BindMarkersFactory factory = BindMarkersFactory.indexed("$", 0); + + BindMarkers bindMarkers1 = factory.create(); + BindMarkers bindMarkers2 = factory.create(); + + assertThat(bindMarkers1.next().getPlaceholder()).isEqualTo("$0"); + assertThat(bindMarkers2.next().getPlaceholder()).isEqualTo("$0"); + } + + @Test // gh-15 + public void nextShouldIncrementBindMarker() { + + BindMarkers bindMarkers = BindMarkersFactory.indexed("$", 0).create(); + + BindMarker marker1 = bindMarkers.next(); + BindMarker marker2 = bindMarkers.next(); + + assertThat(marker1.getPlaceholder()).isEqualTo("$0"); + assertThat(marker2.getPlaceholder()).isEqualTo("$1"); + } + + @Test // gh-15 + public void bindValueShouldBindByIndex() { + + Statement statement = mock(Statement.class); + + BindMarkers bindMarkers = BindMarkersFactory.indexed("$", 0).create(); + + bindMarkers.next().bindValue(statement, "foo"); + bindMarkers.next().bindValue(statement, "bar"); + + verify(statement).bind(0, "foo"); + verify(statement).bind(1, "bar"); + } + + @Test // gh-15 + public void bindNullShouldBindByIndex() { + + Statement statement = mock(Statement.class); + + BindMarkers bindMarkers = BindMarkersFactory.indexed("$", 0).create(); + + bindMarkers.next(); // ignore + + bindMarkers.next().bindNull(statement, Integer.class); + + verify(statement).bindNull(1, Integer.class); + } +} diff --git a/src/test/java/org/springframework/data/r2dbc/dialect/NamedBindMarkersUnitTests.java b/src/test/java/org/springframework/data/r2dbc/dialect/NamedBindMarkersUnitTests.java new file mode 100644 index 0000000..7da8385 --- /dev/null +++ b/src/test/java/org/springframework/data/r2dbc/dialect/NamedBindMarkersUnitTests.java @@ -0,0 +1,90 @@ +package org.springframework.data.r2dbc.dialect; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +import io.r2dbc.spi.Statement; + +import org.junit.Test; + +/** + * Unit tests for {@link NamedBindMarkers}. + * + * @author Mark Paluch + */ +public class NamedBindMarkersUnitTests { + + @Test // gh-15 + public void shouldCreateNewBindMarkers() { + + BindMarkersFactory factory = BindMarkersFactory.named("@", "p", 32); + + BindMarkers bindMarkers1 = factory.create(); + BindMarkers bindMarkers2 = factory.create(); + + assertThat(bindMarkers1.next().getPlaceholder()).isEqualTo("@p0"); + assertThat(bindMarkers2.next().getPlaceholder()).isEqualTo("@p0"); + } + + @Test // gh-15 + public void nextShouldIncrementBindMarker() { + + BindMarkers bindMarkers = BindMarkersFactory.named("@", "p", 32).create(); + + BindMarker marker1 = bindMarkers.next(); + BindMarker marker2 = bindMarkers.next(); + + assertThat(marker1.getPlaceholder()).isEqualTo("@p0"); + assertThat(marker2.getPlaceholder()).isEqualTo("@p1"); + } + + @Test // gh-15 + public void nextShouldConsiderNameHint() { + + BindMarkers bindMarkers = BindMarkersFactory.named("@", "p", 32).create(); + + BindMarker marker1 = bindMarkers.next("foo.bar?"); + BindMarker marker2 = bindMarkers.next(); + + assertThat(marker1.getPlaceholder()).isEqualTo("@p0_foobar"); + assertThat(marker2.getPlaceholder()).isEqualTo("@p1"); + } + + @Test // gh-15 + public void nextShouldConsiderNameLimit() { + + BindMarkers bindMarkers = BindMarkersFactory.named("@", "p", 10).create(); + + BindMarker marker1 = bindMarkers.next("123456789"); + + assertThat(marker1.getPlaceholder()).isEqualTo("@p0_1234567"); + } + + @Test // gh-15 + public void bindValueShouldBindByName() { + + Statement statement = mock(Statement.class); + + BindMarkers bindMarkers = BindMarkersFactory.named("@", "p", 32).create(); + + bindMarkers.next().bindValue(statement, "foo"); + bindMarkers.next().bindValue(statement, "bar"); + + verify(statement).bind("p0", "foo"); + verify(statement).bind("p1", "bar"); + } + + @Test // gh-15 + public void bindNullShouldBindByName() { + + Statement statement = mock(Statement.class); + + BindMarkers bindMarkers = BindMarkersFactory.named("@", "p", 32).create(); + + bindMarkers.next(); // ignore + + bindMarkers.next().bindNull(statement, Integer.class); + + verify(statement).bindNull("p1", Integer.class); + } +}