Skip to content

Commit 972dd75

Browse files
committed
[skip ci] Add README.md with project overview, usage examples, and configuration details
1 parent 24b7c8b commit 972dd75

1 file changed

Lines changed: 377 additions & 0 deletions

File tree

README.md

Lines changed: 377 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,377 @@
1+
# surf-database-r2dbc
2+
3+
`surf-database-r2dbc` is an R2DBC provider for **JetBrains Exposed**, enabling non-blocking database operations in JVM applications.
4+
5+
It provides:
6+
- Integration of Exposed DSL with R2DBC
7+
- Non-blocking, reactive database operations
8+
- Kotlin coroutines support
9+
- Configuration-based connection pool management
10+
- MariaDB/MySQL support via R2DBC
11+
12+
The library is built on top of **JetBrains Exposed**, **R2DBC**, and **Kotlin coroutines**.
13+
14+
---
15+
16+
## Concepts
17+
18+
### DatabaseApi
19+
20+
`DatabaseApi` is the central entry point for database operations.
21+
It manages the R2DBC connection pool and exposes the underlying Exposed [R2dbcDatabase](https://www.jetbrains.com/help/exposed/working-with-database.html#r2dbc).
22+
23+
A typical application creates **exactly one** `DatabaseApi` instance and shares it across the system.
24+
25+
Lifecycle:
26+
1. Create the API from a plugin path (loads configuration)
27+
2. Initialize tables
28+
3. Execute queries using Exposed
29+
4. Shutdown on application termination
30+
31+
```kotlin
32+
val databaseApi = DatabaseApi.create(pluginPath)
33+
34+
// Access the underlying Exposed database
35+
databaseApi.database
36+
```
37+
38+
---
39+
40+
## Service Pattern (recommended)
41+
42+
In most applications, `DatabaseApi` is wrapped inside a service that manages its lifecycle and provides a global access point.
43+
44+
```kotlin
45+
abstract class DatabaseService {
46+
47+
val databaseApi = DatabaseApi.create(
48+
pluginPath = dataFolder.toPath(),
49+
poolName = "my-app-pool"
50+
)
51+
52+
suspend fun connect() {
53+
initializeTables()
54+
}
55+
56+
@MustBeInvokedByOverriders
57+
@ApiStatus.OverrideOnly
58+
protected open suspend fun initializeTables() {
59+
// Initialize database schema using Exposed
60+
// See: https://www.jetbrains.com/help/exposed/working-with-tables.html#dsl-create-table
61+
}
62+
63+
fun disconnect() {
64+
databaseApi.shutdown()
65+
}
66+
67+
companion object {
68+
val instance = requiredService<DatabaseService>()
69+
fun get() = instance
70+
}
71+
}
72+
73+
val databaseApi get() = DatabaseService.get().databaseApi
74+
75+
@AutoService(DatabaseService::class)
76+
class MyDatabaseService : DatabaseService() {
77+
override suspend fun initializeTables() {
78+
super.initializeTables()
79+
// suspendTransaction {
80+
// SchemaUtils.create(UsersTable, ItemsTable)
81+
// }
82+
}
83+
}
84+
```
85+
86+
---
87+
88+
## Configuration
89+
90+
### Database Config File
91+
92+
The `DatabaseApi.create(pluginPath)` method loads configuration from a `database.yml` file located relative to the provided path.
93+
94+
Example `database.yml`:
95+
96+
```yaml
97+
credentials:
98+
host: localhost
99+
port: 3306
100+
username: myuser
101+
password: mypassword
102+
database: mydb
103+
104+
pool:
105+
sizing:
106+
initialSize: 5
107+
minIdle: 5
108+
maxSize: 20
109+
110+
timeouts:
111+
maxAcquireTimeMillis: 30000
112+
maxCreateConnectionTimeMillis: 10000
113+
maxIdleTimeMillis: 600000
114+
maxLifeTimeMillis: 1800000
115+
maxValidationTimeMillis: 5000
116+
117+
logLevel: DEBUG
118+
```
119+
120+
### Pool Name
121+
122+
The optional `poolName` parameter helps identify the connection pool in logs and monitoring:
123+
124+
```kotlin
125+
DatabaseApi.create(
126+
pluginPath = dataFolder.toPath(),
127+
poolName = "my-plugin-pool"
128+
)
129+
```
130+
131+
If not specified, a pool name is auto-generated based on the caller class.
132+
133+
---
134+
135+
## Database Operations
136+
137+
`surf-database-r2dbc` is a **provider**, not a query API. All database operations are performed using **JetBrains Exposed**.
138+
139+
Refer to the official Exposed documentation:
140+
- [Exposed Documentation](https://www.jetbrains.com/help/exposed/dsl-crud-operations.html)
141+
142+
### Example: Simple Query
143+
144+
```kotlin
145+
suspend fun findUserById(id: UUID): User? = suspendTransaction {
146+
UsersTable
147+
.select { UsersTable.id eq id }
148+
.singleOrNull()
149+
?.toUser()
150+
}
151+
```
152+
153+
---
154+
155+
## Testing
156+
157+
For tests, you can bypass configuration loading and provide a [ConnectionFactory](https://r2dbc.io/spec/1.0.0.RELEASE/api/io/r2dbc/spi/ConnectionFactory.html) directly:
158+
159+
```kotlin
160+
@OptIn(TestOnlyDatabaseApi::class)
161+
val databaseApi = DatabaseApi.create(
162+
connectionFactory = myTestConnectionFactory
163+
)
164+
```
165+
166+
This is useful with **Testcontainers** or in-memory databases.
167+
168+
### Example with Testcontainers
169+
170+
```kotlin
171+
@Testcontainers
172+
class DatabaseTest {
173+
174+
companion object {
175+
@Container
176+
val mariaDb = MariaDBContainer("mariadb")
177+
.withDatabaseName("testdb")
178+
}
179+
180+
lateinit var databaseApi: DatabaseApi
181+
182+
@BeforeEach
183+
fun setup() {
184+
val options = ConnectionFactoryOptions.builder()
185+
.option(DRIVER, "mariadb")
186+
.option(HOST, mariaDb.host)
187+
.option(PORT, mariaDb.firstMappedPort)
188+
.option(USER, mariaDb.username)
189+
.option(PASSWORD, mariaDb.password)
190+
.option(DATABASE, mariaDb.databaseName)
191+
.build()
192+
193+
val pool = ConnectionPool(
194+
ConnectionPoolConfiguration.builder()
195+
.connectionFactory(ConnectionFactories.get(options))
196+
.build()
197+
)
198+
199+
databaseApi = DatabaseApi.create(pool)
200+
}
201+
202+
@AfterEach
203+
fun teardown() {
204+
databaseApi.shutdown()
205+
}
206+
}
207+
```
208+
209+
---
210+
211+
## Supported Databases
212+
213+
Currently, the library is configured for **MariaDB** via R2DBC.
214+
215+
The underlying R2DBC architecture supports other databases, but the default connection factory is `MariadbConnectionFactory`. To support other databases, you can:
216+
217+
1. Use the `@TestOnlyDatabaseApi` overload with a custom [ConnectionFactory](https://r2dbc.io/spec/1.0.0.RELEASE/api/io/r2dbc/spi/ConnectionFactory.html)
218+
2. Extend the library to support additional R2DBC drivers
219+
220+
---
221+
222+
## Guarantees & Non-Guarantees
223+
224+
Guaranteed:
225+
226+
* Non-blocking database operations
227+
* Connection pooling with configurable sizing and timeouts
228+
* Integration with Exposed's DSL and type-safe queries
229+
* Proper resource cleanup via `shutdown()`
230+
231+
Not guaranteed:
232+
233+
* Support for non-MariaDB databases without custom setup
234+
* Automatic schema migrations
235+
* Cross-database transactions
236+
237+
---
238+
239+
## Common Pitfalls
240+
241+
### 1. Not calling `shutdown()` on application termination
242+
243+
The connection pool must be closed explicitly:
244+
245+
```kotlin
246+
Runtime.getRuntime().addShutdownHook(Thread {
247+
DatabaseService.get().disconnect()
248+
})
249+
```
250+
251+
Failing to do so may leave connections open and cause resource leaks.
252+
253+
---
254+
255+
### 2. Creating `DatabaseApi` after table initialization
256+
257+
Table initialization requires a database connection. Always create `DatabaseApi` **before** calling `initializeTables()`:
258+
259+
```kotlin
260+
// BAD
261+
suspend fun connect() {
262+
initializeTables() // database not ready yet
263+
databaseApi = DatabaseApi.create(pluginPath)
264+
}
265+
```
266+
267+
```kotlin
268+
// GOOD
269+
suspend fun connect() {
270+
databaseApi = DatabaseApi.create(pluginPath)
271+
initializeTables()
272+
}
273+
```
274+
275+
Or better: initialize `databaseApi` as a class property, then call `initializeTables()` in `connect()`.
276+
277+
---
278+
279+
### 3. Using blocking JDBC instead of R2DBC transactions
280+
281+
Exposed supports both JDBC and R2DBC. This library provides **R2DBC only**.
282+
283+
Always use:
284+
285+
```kotlin
286+
suspendTransaction {
287+
// queries here
288+
}
289+
```
290+
291+
Do **not** use:
292+
293+
```kotlin
294+
transaction {
295+
// This is blocking JDBC, not R2DBC
296+
}
297+
```
298+
299+
---
300+
301+
### 4. Ignoring `suspend` for `initializeTables()`
302+
303+
The `initializeTables()` method must be `suspend` to allow safe schema initialization:
304+
305+
```kotlin
306+
// BAD
307+
protected open fun initializeTables() {
308+
// Cannot use suspending functions here
309+
}
310+
```
311+
312+
```kotlin
313+
// GOOD
314+
protected open suspend fun initializeTables() {
315+
suspendTransaction {
316+
SchemaUtils.create(UsersTable)
317+
}
318+
}
319+
```
320+
321+
---
322+
323+
### 5. Not understanding connection pool limits
324+
325+
The connection pool has a maximum size defined in `database.yml`.
326+
327+
If all connections are in use, new transactions will wait up to `maxAcquireTimeMillis` before failing.
328+
329+
Monitor connection usage and adjust pool sizing if needed:
330+
331+
```yaml
332+
pool:
333+
sizing:
334+
maxSize: 50 # Increase if needed
335+
```
336+
337+
---
338+
339+
### 6. Mixing config-based and manual setup
340+
341+
The config-based `DatabaseApi.create(pluginPath)` overload is for production.
342+
343+
The `ConnectionFactory`-based overload is for tests.
344+
345+
Do **not** mix them:
346+
347+
```kotlin
348+
// BAD
349+
val databaseApi = DatabaseApi.create(pluginPath)
350+
// then later try to create another with manual factory
351+
```
352+
353+
Choose one approach per application lifecycle.
354+
355+
---
356+
357+
## Internal APIs
358+
359+
Some APIs are annotated with `@TestOnlyDatabaseApi`.
360+
361+
These APIs:
362+
363+
* are primarily intended for tests
364+
* bypass config-based setup
365+
* should be avoided in production code
366+
367+
The recommended production entry point is:
368+
369+
```kotlin
370+
DatabaseApi.create(pluginPath, poolName)
371+
```
372+
373+
---
374+
375+
## License
376+
377+
This project is licensed under the GNU General Public License v3.0.

0 commit comments

Comments
 (0)