PostgreSQL
The database-jdbc-postgres module adds column mappers for PostgreSQL-specific representations:
arrays, interval, range types, and json / jsonb.
Repositories, entities, and the jdbc configuration section follow the JDBC documentation;
the module only contributes mappers to the application graph.
Dependency¶
Dependency build.gradle:
Module:
Dependency build.gradle.kts:
Module:
PostgresJdbcDatabaseModule extends JdbcDatabaseModule, so it replaces it in the application rather than being added next to it.
The module brings the PostgreSQL driver org.postgresql:postgresql as a transitive dependency.
PostgresJdbcDatabaseModule combines five mapper modules from io.koraframework.database.jdbc.postgres.mapper:
PgCollectionJdbcMappersModule, PgArrayJdbcMappersModule, PgIntervalJdbcMappersModule, PgRangeJdbcMappersModule, and PgJsonJdbcMappersModule.
When only some of the mappers are needed, extend JdbcDatabaseModule together with the modules you want:
Mappers for regular JDK types are registered with a tag, so they never change how a field is mapped implicitly:
mappers for collections, arrays, Duration, and Period are tagged with @Pg,
and JSON mappers with @PgJson or @PgJsonb (all from io.koraframework.database.jdbc.postgres.annotation).
Put the annotation on an entity field or on a repository method parameter to select the PostgreSQL mapper.
PgRange mappers need no tag because PgRange is PostgreSQL-specific by itself.
Every mapper is declared as a @DefaultComponent, so your own mapper of the same type and tag replaces it.
@EntityJdbc
@Table("users")
public record User(@Id long id,
@Pg List<String> roles, //(1)!
@Pg Duration sessionTtl, //(2)!
PgRange<LocalDate> validity, //(3)!
@PgJsonb Settings settings) { //(4)!
@Json
public record Settings(String theme, boolean notifications) { }
}
@Repository
public interface UserRepository extends JdbcRepository {
@Query("SELECT %{return#selects} FROM %{return#table} WHERE id = ANY(:ids)")
List<User> findAllByIds(@Pg List<Long> ids); //(5)!
@Query("INSERT INTO %{entity#inserts}")
UpdateCount insert(User entity);
}
- Column of type
varchar[] - Column of type
interval - Column of type
daterange, no tag required - Column of type
jsonb,Settingsis written and read by theJsonWriter/JsonReadergenerated for the@Jsontype - The list is bound as a single
int8[]parameter, so= ANY(:ids)needs no hand-written mapper
@EntityJdbc
@Table("users")
data class User(
@field:Id val id: Long,
@Pg val roles: List<String>, //(1)!
@Pg val sessionTtl: Duration, //(2)!
val validity: PgRange<LocalDate>, //(3)!
@PgJsonb val settings: Settings //(4)!
) {
@Json
data class Settings(val theme: String, val notifications: Boolean)
}
@Repository
interface UserRepository : JdbcRepository {
@Query("SELECT %{return#selects} FROM %{return#table} WHERE id = ANY(:ids)")
fun findAllByIds(@Pg ids: List<Long>): List<User> //(5)!
@Query("INSERT INTO %{entity#inserts}")
fun insert(entity: User): UpdateCount
}
- Column of type
varchar[] - Column of type
interval - Column of type
daterange, no tag required - Column of type
jsonb,Settingsis written and read by theJsonWriter/JsonReadergenerated for the@Jsontype - The list is bound as a single
int8[]parameter, so= ANY(:ids)needs no hand-written mapper
Arrays and collections¶
With @Pg, collections and arrays are bound as a PostgreSQL array and read back from one:
| Element type | PostgreSQL array element |
|---|---|
Boolean |
bool |
Short |
int2 |
Integer |
int4 |
Long |
int8 |
Float |
float4 |
Double |
float8 |
BigDecimal |
numeric |
String |
varchar |
UUID |
uuid |
- A parameter can be a
List<T>,Set<T>, orCollection<T>, so a collection does not have to be copied just to make the call. A result is always aList<T>; uniqueness is expressed by the query, for example withDISTINCT. - Arrays are supported as
T[]for every element type above (Array<T>inKotlin) and as primitive arraysboolean[],short[],int[],long[],float[],double[](BooleanArray,ShortArray,IntArray,LongArray,FloatArray,DoubleArrayinKotlin). NULLelements are passed through as is for collections and object arrays. A primitive array cannot holdNULL, so reading an array with aNULLelement into it fails withSQLException.- A
NULLcolumn is read asnull, and anullvalue is bound asNULL.
Intervals¶
With @Pg, java.time.Duration and java.time.Period are bound to and read from an interval column.
Duration is written as days, hours, minutes, and seconds with a fractional part, and Period as years, months, and days.
Months and years have no fixed length, so reading an interval that has years or months into a Duration fails with SQLException;
use Period for such values.
In the same way, reading an interval with a time part into a Period fails; use Duration for it.
Ranges¶
PgRange<T> from io.koraframework.database.jdbc.postgres represents a range type value and needs no tag:
Java type |
PostgreSQL type |
|---|---|
PgRange<Integer> |
int4range |
PgRange<Long> |
int8range |
PgRange<BigDecimal> |
numrange |
PgRange<LocalDate> |
daterange |
PgRange<LocalDateTime> |
tsrange |
PgRange<OffsetDateTime> |
tstzrange |
PgRange is a record of lower, upper, lowerInclusive, upperInclusive, and isEmpty; a null bound means the range is unbounded on that side.
Factory methods cover the usual shapes: closed [a,b], closedOpen [a,b), openClosed (a,b], open (a,b), and empty().
@Repository
public interface UserRepository extends JdbcRepository {
@Query("SELECT %{return#selects} FROM %{return#table} WHERE validity && :period")
List<User> findValidWithin(PgRange<LocalDate> period);
}
var users = repository.findValidWithin(PgRange.closedOpen(LocalDate.of(2026, 1, 1), null)); //(1)!
[2026-01-01,): from January 1, 2026 with no upper bound
@Repository
interface UserRepository : JdbcRepository {
@Query("SELECT %{return#selects} FROM %{return#table} WHERE validity && :period")
fun findValidWithin(period: PgRange<LocalDate>): List<User>
}
val users = repository.findValidWithin(PgRange.closedOpen(LocalDate.of(2026, 1, 1), null)) //(1)!
[2026-01-01,): from January 1, 2026 with no upper bound
PostgreSQL normalizes discrete range types (int4range, int8range, daterange) to the [) form and degenerate ranges to empty,
so a value read back may differ from the one written: PgRange.closed(1, 5) is read back as [1,6).
tstzrange bounds are returned in the session time zone, so they denote the same instant but may carry a different offset.
JSON¶
@PgJson maps a value to a json column and @PgJsonb to a jsonb column, through the JSON module
JsonWriter<T> / JsonReader<T> of the value type, so the value type must have them, for example by being annotated with @Json.
The two tags differ in the type of the bound parameter: jsonb operators such as @>, ?, and jsonb_path_query require the parameter to be exactly jsonb,
so use @PgJsonb for jsonb columns. No ::jsonb cast is needed in the query.
A NULL column is read as null, and a null value is bound as NULL.
To tell a NULL column apart from a JSON null literal, declare the field as JsonNullable<T>:
JsonNullable.undefined() corresponds to NULL in the column, JsonNullable.nullValue() to the JSON null literal,
and a defined value to the serialized value.