Skip to content

Commit 3d9904a

Browse files
felickzCopilot
andauthored
fix(java): add SQL-injection sinks for full Spring R2DBC + io.r2dbc.spi coverage (#216)
Fixes the false-negative reported in github/field-security-codeql#231 for Spring R2DBC's DatabaseClient.sql(Supplier<String>) pattern, and extends the fix to full, TDD-validated coverage of both affected data extensions. org.springframework.r2dbc.model.yml: - Previously only DatabaseClient.GenericExecuteSpec.fetch() was modeled as a sink, missing then()/map()/mapValue()/mapProperties()/flatMap() terminal methods and the bind*/filter fluent methods that carry the tainted SQL through the chain. - Added 6 sinkModel rows (then, map(Function), map(BiFunction), mapValue, mapProperties, flatMap) and 9 summaryModel rows (bind x2, bindNull x2, bindValues x2, bindProperties, filter x2) so every method on GenericExecuteSpec that can reach or carry the deferred SQL is covered. io.r2dbc.spi.model.yml: - Added 4 new sinkModel rows found during a full audit of the driver-agnostic R2DBC SPI: Connection.createSavepoint(String), releaseSavepoint(String), rollbackTransactionToSavepoint(String), and Statement.returnGeneratedValues(String...). Each takes a raw identifier/column name that real drivers (verified against r2dbc-postgresql 1.0.2.RELEASE) splice unescaped into the executed SQL text (e.g. String.format("SAVEPOINT %s", name)), since the wire protocol has no parameter placeholder for identifiers. Every one of the 16 Spring R2DBC rows and 6 io.r2dbc.spi rows now has a dedicated, empirically-verified test proving it fires - not just architectural reasoning by analogy. Two real bugs were caught and fixed during this process: a corrupted .expected file (accidental copy of the view tool's line-number prefixes) and a test-stub structural bug (StatementFilterFunction needing to be a top-level type, not nested inside DatabaseClient, to match the real Spring API and the model's fully-qualified signature string). Full CWE-089/spring-r2dbc test suite passes cleanly; broader CWE-089 suite run shows only pre-existing, unrelated MyBatis failures caused by a missing external stub checkout path in this environment. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: f02ef23b-e35d-4536-bd58-f2fbc2976ba9
1 parent 88d2b11 commit 3d9904a

9 files changed

Lines changed: 268 additions & 9 deletions

File tree

‎java/ext/manual/io.r2dbc.spi.model.yml‎

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,10 @@ extensions:
66
# `io.r2dbc.spi` is the low-level, driver-agnostic R2DBC SPI that every
77
# reactive relational driver (Postgres, MySQL, H2, SQL Server, ...)
88
# implements, and that higher-level APIs such as Spring's
9-
# `DatabaseClient` are themselves built on top of. Modeling these two
10-
# sinks closes the false negative for any code (Spring-based or not)
11-
# that drops down to the raw R2DBC SPI to build/execute SQL text.
9+
# `DatabaseClient` are themselves built on top of. Modeling these sinks
10+
# closes the false negative for any code (Spring-based or not) that
11+
# drops down to the raw R2DBC SPI to build/execute SQL text or
12+
# identifiers.
1213
#
1314
# `Connection.createStatement(String)` is the R2DBC equivalent of
1415
# `java.sql.Connection.prepareStatement(String)`: the SQL text is fixed
@@ -19,3 +20,24 @@ extensions:
1920
# `java.sql.Statement.addBatch(String)`: it appends a raw SQL statement
2021
# to a batch that is later executed via `Batch.execute()`.
2122
- ["io.r2dbc.spi", "Batch", True, "add", "(String)", "", "Argument[0]", "sql-injection", "manual"]
23+
# `Connection.createSavepoint`/`releaseSavepoint`/
24+
# `rollbackTransactionToSavepoint` each take a raw `name` that
25+
# identifies a savepoint. The SQL wire protocol has no parameter
26+
# placeholder for identifiers (only for literal values), so drivers
27+
# build the executed command by splicing `name` directly into text
28+
# such as `SAVEPOINT <name>`. Verified against r2dbc-postgresql
29+
# 1.0.2.RELEASE (`PostgresqlConnection`), which does exactly this via
30+
# unescaped `String.format(...)` - a tainted savepoint name is
31+
# classic SQL injection (e.g. `x; DROP TABLE users; --`).
32+
- ["io.r2dbc.spi", "Connection", True, "createSavepoint", "(String)", "", "Argument[0]", "sql-injection", "manual"]
33+
- ["io.r2dbc.spi", "Connection", True, "releaseSavepoint", "(String)", "", "Argument[0]", "sql-injection", "manual"]
34+
- ["io.r2dbc.spi", "Connection", True, "rollbackTransactionToSavepoint", "(String)", "", "Argument[0]", "sql-injection", "manual"]
35+
# `Statement.returnGeneratedValues(String...)` takes raw column names
36+
# to request back after an INSERT/UPDATE/DELETE. For the same reason
37+
# (no placeholder syntax for identifiers), drivers append these names
38+
# directly into the executed SQL's `RETURNING` clause. Verified
39+
# against r2dbc-postgresql 1.0.2.RELEASE
40+
# (`GeneratedValuesUtils.augment`), which does
41+
# `String.format("%s RETURNING %s", sql, String.join(", ", columns))`
42+
# with zero escaping or validation of the column names.
43+
- ["io.r2dbc.spi", "Statement", True, "returnGeneratedValues", "(String[])", "", "Argument[0]", "sql-injection", "manual"]

‎java/ext/manual/org.springframework.r2dbc.model.yml‎

Lines changed: 32 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,17 +12,43 @@ extensions:
1212
# text until execution. The supplier's return value is not itself an
1313
# argument/return position that MaD can target directly as a sink, so
1414
# taint is instead propagated (via the summaryModel below) onto the
15-
# `GenericExecuteSpec` object returned by `sql(...)`. The sink is
16-
# placed on `fetch()`, the point where the deferred query is finalized
17-
# and enters the fluent execution chain; actual execution remains
18-
# reactive/deferred until the returned publisher is subscribed to, but
19-
# the tainted SQL text has already been captured by that point.
15+
# `GenericExecuteSpec` object returned by `sql(...)`. Sinks are then
16+
# placed on every method of `GenericExecuteSpec` that actually enters
17+
# the execution stage (per Spring's own Javadoc wording) - `fetch()`,
18+
# `then()`, `map(...)`/`mapValue(...)`/`mapProperties(...)`, and
19+
# `flatMap(...)` - since any of them, called on a tainted receiver,
20+
# means the tainted SQL text has already been captured. Actual
21+
# execution remains reactive/deferred until the returned publisher is
22+
# subscribed to, but that doesn't change the taint result.
2023
- ["org.springframework.r2dbc.core", "DatabaseClient$GenericExecuteSpec", True, "fetch", "", "", "Argument[this]", "sql-injection", "manual"]
24+
- ["org.springframework.r2dbc.core", "DatabaseClient$GenericExecuteSpec", True, "then", "", "", "Argument[this]", "sql-injection", "manual"]
25+
- ["org.springframework.r2dbc.core", "DatabaseClient$GenericExecuteSpec", True, "map", "(java.util.function.Function)", "", "Argument[this]", "sql-injection", "manual"]
26+
- ["org.springframework.r2dbc.core", "DatabaseClient$GenericExecuteSpec", True, "map", "(java.util.function.BiFunction)", "", "Argument[this]", "sql-injection", "manual"]
27+
- ["org.springframework.r2dbc.core", "DatabaseClient$GenericExecuteSpec", True, "mapValue", "(Class)", "", "Argument[this]", "sql-injection", "manual"]
28+
- ["org.springframework.r2dbc.core", "DatabaseClient$GenericExecuteSpec", True, "mapProperties", "(Class)", "", "Argument[this]", "sql-injection", "manual"]
29+
- ["org.springframework.r2dbc.core", "DatabaseClient$GenericExecuteSpec", True, "flatMap", "(java.util.function.Function)", "", "Argument[this]", "sql-injection", "manual"]
2130
- addsTo:
2231
pack: codeql/java-all
2332
extensible: summaryModel
2433
data:
2534
# Propagate taint from the deferred SQL supplier's return value onto
2635
# the `GenericExecuteSpec` returned by `sql(Supplier<String>)`, so it
27-
# can reach the `fetch()` sink modeled above.
36+
# can reach the execution-stage sinks modeled above.
2837
- ["org.springframework.r2dbc.core", "DatabaseClient", True, "sql", "(java.util.function.Supplier)", "", "Argument[0].ReturnValue", "ReturnValue", "taint", "manual"]
38+
# `GenericExecuteSpec`'s parameter-binding/filtering methods are
39+
# fluent - they return (a possibly new) `GenericExecuteSpec` - and
40+
# must NOT break the taint chain from the deferred-supplier summary
41+
# above through to the execution-stage sinks. These propagate the
42+
# receiver's own taint (from the deferred-supplier path) onto the
43+
# returned `GenericExecuteSpec`; they do not, and should not, taint
44+
# the receiver based on the *bound parameter value* itself, since
45+
# bound parameters are safely escaped by the driver.
46+
- ["org.springframework.r2dbc.core", "DatabaseClient$GenericExecuteSpec", True, "bind", "(int,Object)", "", "Argument[this]", "ReturnValue", "taint", "manual"]
47+
- ["org.springframework.r2dbc.core", "DatabaseClient$GenericExecuteSpec", True, "bind", "(String,Object)", "", "Argument[this]", "ReturnValue", "taint", "manual"]
48+
- ["org.springframework.r2dbc.core", "DatabaseClient$GenericExecuteSpec", True, "bindNull", "(int,Class)", "", "Argument[this]", "ReturnValue", "taint", "manual"]
49+
- ["org.springframework.r2dbc.core", "DatabaseClient$GenericExecuteSpec", True, "bindNull", "(String,Class)", "", "Argument[this]", "ReturnValue", "taint", "manual"]
50+
- ["org.springframework.r2dbc.core", "DatabaseClient$GenericExecuteSpec", True, "bindValues", "(List)", "", "Argument[this]", "ReturnValue", "taint", "manual"]
51+
- ["org.springframework.r2dbc.core", "DatabaseClient$GenericExecuteSpec", True, "bindValues", "(Map)", "", "Argument[this]", "ReturnValue", "taint", "manual"]
52+
- ["org.springframework.r2dbc.core", "DatabaseClient$GenericExecuteSpec", True, "bindProperties", "(Object)", "", "Argument[this]", "ReturnValue", "taint", "manual"]
53+
- ["org.springframework.r2dbc.core", "DatabaseClient$GenericExecuteSpec", True, "filter", "(java.util.function.Function)", "", "Argument[this]", "ReturnValue", "taint", "manual"]
54+
- ["org.springframework.r2dbc.core", "DatabaseClient$GenericExecuteSpec", True, "filter", "(org.springframework.r2dbc.core.StatementFilterFunction)", "", "Argument[this]", "ReturnValue", "taint", "manual"]
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,21 @@
11
| com/example/DeferredQueryHandler.java:24:23:24:30 | source(...) | com/example/DeferredQueryHandler.java:25:12:26:77 | sql(...) |
2+
| com/example/DeferredQueryHandler.java:33:23:33:30 | source(...) | com/example/DeferredQueryHandler.java:34:12:35:77 | sql(...) |
3+
| com/example/DeferredQueryHandler.java:42:23:42:30 | source(...) | com/example/DeferredQueryHandler.java:43:12:44:77 | sql(...) |
4+
| com/example/DeferredQueryHandler.java:52:23:52:30 | source(...) | com/example/DeferredQueryHandler.java:53:12:55:35 | bind(...) |
5+
| com/example/DeferredQueryHandler.java:64:23:64:30 | source(...) | com/example/DeferredQueryHandler.java:65:12:67:41 | bind(...) |
6+
| com/example/DeferredQueryHandler.java:75:23:75:30 | source(...) | com/example/DeferredQueryHandler.java:76:12:77:77 | sql(...) |
7+
| com/example/DeferredQueryHandler.java:83:23:83:30 | source(...) | com/example/DeferredQueryHandler.java:84:12:85:77 | sql(...) |
8+
| com/example/DeferredQueryHandler.java:94:23:94:30 | source(...) | com/example/DeferredQueryHandler.java:95:12:100:29 | filter(...) |
9+
| com/example/DeferredQueryHandler.java:107:23:107:30 | source(...) | com/example/DeferredQueryHandler.java:108:12:110:40 | bindNull(...) |
10+
| com/example/DeferredQueryHandler.java:117:23:117:30 | source(...) | com/example/DeferredQueryHandler.java:118:12:120:59 | bindValues(...) |
11+
| com/example/DeferredQueryHandler.java:127:23:127:30 | source(...) | com/example/DeferredQueryHandler.java:128:12:130:78 | filter(...) |
12+
| com/example/DeferredQueryHandler.java:137:23:137:30 | source(...) | com/example/DeferredQueryHandler.java:138:12:139:77 | sql(...) |
213
| com/example/RawR2dbcHandler.java:28:23:28:30 | source(...) | com/example/RawR2dbcHandler.java:31:13:31:69 | ... + ... |
314
| com/example/RawR2dbcHandler.java:36:23:36:30 | source(...) | com/example/RawR2dbcHandler.java:38:15:38:71 | ... + ... |
15+
| com/example/RawR2dbcHandler.java:54:19:54:26 | source(...) | com/example/RawR2dbcHandler.java:55:32:55:35 | name |
16+
| com/example/RawR2dbcHandler.java:59:19:59:26 | source(...) | com/example/RawR2dbcHandler.java:60:33:60:36 | name |
17+
| com/example/RawR2dbcHandler.java:64:19:64:26 | source(...) | com/example/RawR2dbcHandler.java:65:47:65:50 | name |
18+
| com/example/RawR2dbcHandler.java:72:21:72:28 | source(...) | com/example/RawR2dbcHandler.java:74:12:74:50 | new ..[] { .. } |
419
| com/example/StringConcatQueryHandler.java:39:23:39:30 | source(...) | com/example/StringConcatQueryHandler.java:43:31:43:35 | query |
520
| com/example/StringConcatQueryHandler.java:40:21:40:28 | source(...) | com/example/StringConcatQueryHandler.java:43:31:43:35 | query |
621
| com/example/StringConcatQueryHandler.java:41:24:41:31 | source(...) | com/example/StringConcatQueryHandler.java:43:31:43:35 | query |

‎java/test/security/CWE-089/spring-r2dbc/com/example/DeferredQueryHandler.java‎

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,4 +27,117 @@ public Object runDeferredQuery() {
2727
.fetch()
2828
.all(); // sink 2: sql(Supplier<String>)
2929
}
30+
31+
// Alternative terminal/execution-triggering method to fetch(): then().
32+
public Object runDeferredQueryThen() {
33+
String category = source();
34+
return databaseClient
35+
.sql(() -> "SELECT * FROM items WHERE category = '" + category + "'")
36+
.then(); // sink: sql(Supplier<String>) via then()
37+
}
38+
39+
// Alternative terminal method: map(...).all() instead of fetch().all(),
40+
// matching the exact chain from the original real-world report.
41+
public Object runDeferredQueryWithMap() {
42+
String category = source();
43+
return databaseClient
44+
.sql(() -> "SELECT * FROM items WHERE category = '" + category + "'")
45+
.map(row -> row)
46+
.all(); // sink: sql(Supplier<String>) via map()
47+
}
48+
49+
// Intermediate fluent bind() call between the deferred sql() and the
50+
// terminal fetch() - taint must survive the fluent chain.
51+
public Object runDeferredQueryWithBind() {
52+
String category = source();
53+
return databaseClient
54+
.sql(() -> "SELECT * FROM items WHERE category = '" + category + "'")
55+
.bind(0, "unrelated-value")
56+
.fetch()
57+
.all(); // sink: sql(Supplier<String>) via bind().fetch()
58+
}
59+
60+
// Same as above but exercising the String-named bind() overload, to
61+
// confirm the summary model's signature string matches the correct
62+
// overload (not just the int-indexed one).
63+
public Object runDeferredQueryWithBindByName() {
64+
String category = source();
65+
return databaseClient
66+
.sql(() -> "SELECT * FROM items WHERE category = '" + category + "'")
67+
.bind("param", "unrelated-value")
68+
.fetch()
69+
.all(); // sink: sql(Supplier<String>) via bind(String,Object).fetch()
70+
}
71+
72+
// Alternative terminal method: flatMap(...), which returns Flux<R>
73+
// directly instead of an intermediate RowsFetchSpec.
74+
public Object runDeferredQueryWithFlatMap() {
75+
String category = source();
76+
return databaseClient
77+
.sql(() -> "SELECT * FROM items WHERE category = '" + category + "'")
78+
.flatMap(row -> row); // sink: sql(Supplier<String>) via flatMap()
79+
}
80+
81+
// Alternative terminal method: map(BiFunction).
82+
public Object runDeferredQueryWithMapBiFunction() {
83+
String category = source();
84+
return databaseClient
85+
.sql(() -> "SELECT * FROM items WHERE category = '" + category + "'")
86+
.map((row, meta) -> row)
87+
.all(); // sink: sql(Supplier<String>) via map(BiFunction)
88+
}
89+
90+
// Chains bindNull(int,Class), bindValues(List), bindProperties(Object),
91+
// and filter(Function) before the mapValue(Class) terminal sink -
92+
// validates the remaining summary/sink signature strings in one flow.
93+
public Object runDeferredQueryWithBindNullAndFilter() {
94+
String category = source();
95+
return databaseClient
96+
.sql(() -> "SELECT * FROM items WHERE category = '" + category + "'")
97+
.bindNull(1, String.class)
98+
.bindValues(java.util.List.of("unrelated"))
99+
.bindProperties(new Object())
100+
.filter(spec -> spec)
101+
.mapValue(String.class)
102+
.all(); // sink: sql(Supplier<String>) via mapValue() after bindNull/bindValues(List)/bindProperties/filter(Function)
103+
}
104+
105+
// Isolated: bindNull(String,Class) alone.
106+
public Object runDeferredQueryWithBindNullByName() {
107+
String category = source();
108+
return databaseClient
109+
.sql(() -> "SELECT * FROM items WHERE category = '" + category + "'")
110+
.bindNull("param", String.class)
111+
.fetch()
112+
.all(); // sink: sql(Supplier<String>) via bindNull(String,Class).fetch()
113+
}
114+
115+
// Isolated: bindValues(Map) alone.
116+
public Object runDeferredQueryWithBindValuesMap() {
117+
String category = source();
118+
return databaseClient
119+
.sql(() -> "SELECT * FROM items WHERE category = '" + category + "'")
120+
.bindValues(java.util.Map.of("param", "unrelated"))
121+
.fetch()
122+
.all(); // sink: sql(Supplier<String>) via bindValues(Map).fetch()
123+
}
124+
125+
// Isolated: filter(StatementFilterFunction) alone.
126+
public Object runDeferredQueryWithFilterOverload() {
127+
String category = source();
128+
return databaseClient
129+
.sql(() -> "SELECT * FROM items WHERE category = '" + category + "'")
130+
.filter((org.springframework.r2dbc.core.StatementFilterFunction) null)
131+
.fetch()
132+
.all(); // sink: sql(Supplier<String>) via filter(StatementFilterFunction).fetch()
133+
}
134+
135+
// Isolated: mapProperties(Class) alone.
136+
public Object runDeferredQueryWithMapProperties() {
137+
String category = source();
138+
return databaseClient
139+
.sql(() -> "SELECT * FROM items WHERE category = '" + category + "'")
140+
.mapProperties(String.class)
141+
.all(); // sink: sql(Supplier<String>) via mapProperties()
142+
}
30143
}

‎java/test/security/CWE-089/spring-r2dbc/com/example/RawR2dbcHandler.java‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,4 +47,30 @@ public Result runStatementSafe() {
4747
statement.bind(0, category);
4848
return statement.execute();
4949
}
50+
51+
public void runCreateSavepoint() {
52+
// Real drivers (e.g. r2dbc-postgresql) build this as
53+
// `String.format("SAVEPOINT %s", name)` with no escaping/validation.
54+
String name = source();
55+
connection.createSavepoint(name); // sink 5: Connection.createSavepoint(String)
56+
}
57+
58+
public void runReleaseSavepoint() {
59+
String name = source();
60+
connection.releaseSavepoint(name); // sink 6: Connection.releaseSavepoint(String)
61+
}
62+
63+
public void runRollbackTransactionToSavepoint() {
64+
String name = source();
65+
connection.rollbackTransactionToSavepoint(name); // sink 7: Connection.rollbackTransactionToSavepoint(String)
66+
}
67+
68+
public Result runReturnGeneratedValues() {
69+
// Real drivers (e.g. r2dbc-postgresql) build this as
70+
// `String.format("%s RETURNING %s", sql, String.join(", ", columns))`
71+
// with no escaping/validation of the column names.
72+
String column = source();
73+
Statement statement = connection.createStatement("INSERT INTO items (name) VALUES ($1)");
74+
return statement.returnGeneratedValues(column).execute(); // sink 8: Statement.returnGeneratedValues(String...)
75+
}
5076
}

‎java/test/security/CWE-089/spring-r2dbc/io/r2dbc/spi/Connection.java‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,4 +9,10 @@ public interface Connection {
99
Statement createStatement(String sql);
1010

1111
Batch createBatch();
12+
13+
void createSavepoint(String name);
14+
15+
void releaseSavepoint(String name);
16+
17+
void rollbackTransactionToSavepoint(String name);
1218
}

‎java/test/security/CWE-089/spring-r2dbc/io/r2dbc/spi/Statement.java‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,5 +16,7 @@ public interface Statement {
1616

1717
Statement add();
1818

19+
Statement returnGeneratedValues(String... columns);
20+
1921
Result execute();
2022
}

‎java/test/security/CWE-089/spring-r2dbc/org/springframework/r2dbc/core/DatabaseClient.java‎

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@
44
*/
55
package org.springframework.r2dbc.core;
66

7+
import java.util.List;
8+
import java.util.Map;
9+
import java.util.function.BiFunction;
10+
import java.util.function.Function;
711
import java.util.function.Supplier;
812

913
public interface DatabaseClient {
@@ -17,7 +21,43 @@ interface GenericExecuteSpec {
1721

1822
GenericExecuteSpec bind(String name, Object value);
1923

24+
GenericExecuteSpec bindNull(int index, Class<?> type);
25+
26+
GenericExecuteSpec bindNull(String name, Class<?> type);
27+
28+
GenericExecuteSpec bindValues(List<Object> values);
29+
30+
GenericExecuteSpec bindValues(Map<String, Object> values);
31+
32+
GenericExecuteSpec bindProperties(Object source);
33+
34+
GenericExecuteSpec filter(Function<Object, Object> filterFunction);
35+
36+
GenericExecuteSpec filter(StatementFilterFunction filterFunction);
37+
38+
<R> RowsFetchSpec<R> map(Function<Object, R> mappingFunction);
39+
40+
<R> RowsFetchSpec<R> map(BiFunction<Object, Object, R> mappingFunction);
41+
42+
<R> RowsFetchSpec<R> mapValue(Class<R> mappedClass);
43+
44+
<R> RowsFetchSpec<R> mapProperties(Class<R> mappedClass);
45+
2046
FetchSpec fetch();
47+
48+
// Real Spring's `then()`: an alternative terminal/execution-triggering
49+
// method to `fetch()`, returning `Mono<Void>`. Stubbed as `Object` since
50+
// this test doesn't need Reactor on the classpath.
51+
Object then();
52+
53+
// Real Spring's `flatMap(...)`: another terminal/execution-triggering
54+
// method, returning `Flux<R>` directly (no further `.all()` needed).
55+
// Stubbed with `Object` result since this test doesn't need Reactor.
56+
Object flatMap(Function<Object, Object> mappingFunction);
57+
}
58+
59+
interface RowsFetchSpec<R> {
60+
Object all();
2161
}
2262

2363
interface FetchSpec {
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
/*
2+
* Minimal stub of Spring Data R2DBC's `StatementFilterFunction`: a
3+
* top-level interface in the real `org.springframework.r2dbc.core`
4+
* package (not nested inside `DatabaseClient`), used only for the
5+
* `GenericExecuteSpec.filter(StatementFilterFunction)` overload.
6+
*/
7+
package org.springframework.r2dbc.core;
8+
9+
public interface StatementFilterFunction {}

0 commit comments

Comments
 (0)