Skip to content

Commit 7d1c368

Browse files
authored
Merge pull request #165 from alien11689/add-distinct-on-postgresql-extension
Add distinct on postgresql extension
2 parents 9faf272 + ca74263 commit 7d1c368

File tree

8 files changed

+323
-1
lines changed

8 files changed

+323
-1
lines changed

README.md

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -316,6 +316,52 @@ object MyTable : Table("test") {
316316
}
317317
```
318318

319+
### Support for Postgresql `distinct on (...)`
320+
321+
Postgresql allows usage of nonstandard clause [`DISTINCT ON` in queries](https://www.postgresql.org/docs/current/sql-select.html).
322+
323+
Krush provides custom `distinctOn` extension method which can be used as first parameter in custom `slice` extension method.
324+
325+
**Postgresql specific extensions needs `krush-runtime-postgresql` dependency in maven or gradle**
326+
327+
Example code:
328+
329+
```kotlin
330+
@JvmInline
331+
value class MyStringId(val raw: String)
332+
333+
@JvmInline
334+
value class MyVersion(val raw: Int)
335+
336+
fun Table.myStringId(name: String) = stringWrapper(name, ::MyStringId) { it.raw }
337+
338+
fun Table.myVersion(name: String) = integerWrapper(name, ::MyVersion) { it.raw }
339+
340+
341+
object MyTable : Table("test") {
342+
val id = myStringId("my_id").nullable()
343+
val version = myVersion("my_version").nullable()
344+
val content = jsonb("content").nullable()
345+
}
346+
347+
fun findNewestContentVersion(id: MyStringId): String? =
348+
MyTable
349+
.slice(MyTable.id.distinctOn(), MyTable.content)
350+
.select { MyTable.id eq id }
351+
.orderBy(MyTable.id to SortOrder.ASC, MyTable.version to SortOrder.DESC)
352+
.map { it[MyTable.content] }
353+
.firstOrNull()
354+
```
355+
356+
when `findNewestContentVersion(MyStringId("123"))` is called will generate SQL:
357+
358+
```postgresql
359+
SELECT DISTINCT ON (test.my_id) TRUE, test.my_id, test."content"
360+
FROM test
361+
WHERE test.my_id = '123'
362+
ORDER BY test.my_id ASC, test.my_version DESC
363+
```
364+
319365
### Example projects
320366

321367
* [https://github.com/TouK/kotlin-exposed-realworld](https://github.com/TouK/kotlin-exposed-realworld)
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
package pl.touk.krush.distincton
2+
3+
import org.jetbrains.exposed.sql.SortOrder
4+
import org.jetbrains.exposed.sql.Table
5+
import org.jetbrains.exposed.sql.insert
6+
import org.jetbrains.exposed.sql.max
7+
import org.jetbrains.exposed.sql.select
8+
import pl.touk.krush.distinctOn
9+
import pl.touk.krush.jsonb
10+
import pl.touk.krush.slice
11+
12+
object SimpleVersionedJsonContent : Table("simple_versioned_json_content") {
13+
val id = contentId("id")
14+
val version = version("version")
15+
val content = jsonb("content")
16+
17+
fun insert(id: ContentId, content: String) {
18+
SimpleVersionedJsonContent.insert {
19+
it[SimpleVersionedJsonContent.id] = id
20+
it[SimpleVersionedJsonContent.content] = content
21+
it[SimpleVersionedJsonContent.version] = findNewestVersion(id)?.increment() ?: Version.initial
22+
}
23+
}
24+
25+
fun findNewestContent(id: ContentId): String? =
26+
SimpleVersionedJsonContent
27+
.slice(SimpleVersionedJsonContent.id.distinctOn(), SimpleVersionedJsonContent.content)
28+
.select { SimpleVersionedJsonContent.id eq id }
29+
.orderBy(SimpleVersionedJsonContent.id to SortOrder.ASC, SimpleVersionedJsonContent.version to SortOrder.DESC)
30+
.map { it[SimpleVersionedJsonContent.content] }
31+
.firstOrNull()
32+
33+
fun findOldestContent(id: ContentId): String? =
34+
SimpleVersionedJsonContent
35+
.slice(SimpleVersionedJsonContent.id.distinctOn(), SimpleVersionedJsonContent.content)
36+
.select { SimpleVersionedJsonContent.id eq id }
37+
.orderBy(SimpleVersionedJsonContent.id to SortOrder.ASC, SimpleVersionedJsonContent.version to SortOrder.ASC)
38+
.map { it[SimpleVersionedJsonContent.content] }
39+
.firstOrNull()
40+
41+
private fun findNewestVersion(id: ContentId): Version? =
42+
SimpleVersionedJsonContent
43+
.slice(SimpleVersionedJsonContent.version.max())
44+
.select { SimpleVersionedJsonContent.id eq id }
45+
.map { it[SimpleVersionedJsonContent.version.max()] }
46+
.firstOrNull()
47+
}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
package pl.touk.krush.distincton
2+
3+
import org.jetbrains.exposed.sql.SortOrder
4+
import org.jetbrains.exposed.sql.Table
5+
import org.jetbrains.exposed.sql.and
6+
import org.jetbrains.exposed.sql.insert
7+
import org.jetbrains.exposed.sql.max
8+
import org.jetbrains.exposed.sql.select
9+
import org.jetbrains.exposed.sql.selectAll
10+
import pl.touk.krush.distinctOn
11+
import pl.touk.krush.jsonb
12+
import pl.touk.krush.slice
13+
14+
data class TenantJsonContent(val id: ContentId, val tenantId: TenantId, val content: String)
15+
16+
object TenantVersionedJsonContent : Table("tenant_versioned_json_content") {
17+
val id = contentId("id")
18+
val tenantId = tenantId("tenant_id")
19+
val version = version("version")
20+
val content = jsonb("content")
21+
22+
fun insert(tenantJsonContent: TenantJsonContent) {
23+
val currentVersion = findNewestVersion(tenantJsonContent.id, tenantJsonContent.tenantId)
24+
TenantVersionedJsonContent.insert {
25+
it[id] = tenantJsonContent.id
26+
it[tenantId] = tenantJsonContent.tenantId
27+
it[content] = tenantJsonContent.content
28+
it[version] = currentVersion?.increment() ?: Version.initial
29+
}
30+
}
31+
32+
fun getNewestContents(): List<TenantJsonContent> =
33+
TenantVersionedJsonContent
34+
.slice(distinctOn(id, tenantId), content)
35+
.selectAll()
36+
.orderBy(id to SortOrder.ASC, tenantId to SortOrder.ASC, version to SortOrder.DESC)
37+
.map {
38+
TenantJsonContent(
39+
id = it[id],
40+
tenantId = it[tenantId],
41+
content = it[content],
42+
)
43+
}
44+
45+
private fun findNewestVersion(id: ContentId, tenantId: TenantId): Version? =
46+
TenantVersionedJsonContent
47+
.slice(version.max())
48+
.select { TenantVersionedJsonContent.id eq id and (TenantVersionedJsonContent.tenantId eq tenantId) }
49+
.map { it[version.max()] }
50+
.firstOrNull()
51+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
package pl.touk.krush.distincton
2+
3+
import org.jetbrains.exposed.sql.Table
4+
import pl.touk.krush.integerWrapper
5+
import pl.touk.krush.uuidWrapper
6+
import java.util.UUID
7+
8+
@JvmInline
9+
value class ContentId(val raw: UUID) {
10+
companion object {
11+
fun generate(): ContentId = ContentId(UUID.randomUUID())
12+
}
13+
}
14+
15+
@JvmInline
16+
value class TenantId(val raw: UUID) {
17+
companion object {
18+
fun generate(): TenantId = TenantId(UUID.randomUUID())
19+
}
20+
}
21+
22+
@JvmInline
23+
value class Version(val raw: Int) : Comparable<Version> {
24+
override fun compareTo(other: Version): Int = raw.compareTo(other.raw)
25+
fun increment(): Version = Version(raw + 1)
26+
27+
companion object {
28+
val initial: Version = Version(1)
29+
}
30+
}
31+
32+
fun Table.contentId(name: String) = uuidWrapper(name, ::ContentId) { it.raw }
33+
fun Table.tenantId(name: String) = uuidWrapper(name, ::TenantId) { it.raw }
34+
fun Table.version(name: String) = integerWrapper(name, ::Version) { it.raw }

example/src/main/resources/db/migration/V1__tables.sql

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,4 +36,21 @@ CREATE TABLE IF NOT EXISTS reasons (
3636
id serial primary key,
3737
reason varchar,
3838
details json
39-
)
39+
);
40+
41+
CREATE TABLE IF NOT EXISTS simple_versioned_json_content
42+
(
43+
id UUID NOT NULL,
44+
version INT NOT NULL,
45+
content jsonb NOT NULL,
46+
CONSTRAINT pk_simple_versioned_json_content PRIMARY KEY (id, version)
47+
);
48+
49+
CREATE TABLE IF NOT EXISTS tenant_versioned_json_content
50+
(
51+
id UUID NOT NULL,
52+
tenant_id UUID NOT NULL,
53+
version INT NOT NULL,
54+
content jsonb NOT NULL,
55+
CONSTRAINT pk_tenant_versioned_json_content PRIMARY KEY (id, tenant_id, version)
56+
);
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
package pl.touk.krush.distincton
2+
3+
import org.assertj.core.api.Assertions.assertThat
4+
import org.jetbrains.exposed.sql.transactions.transaction
5+
import org.junit.jupiter.api.BeforeAll
6+
import org.junit.jupiter.api.Test
7+
import pl.touk.krush.base.BaseDatabaseTest
8+
9+
class SimpleVersionedJsonContentTest : BaseDatabaseTest() {
10+
11+
private val contentId1 = ContentId.generate()
12+
private val contentId2 = ContentId.generate()
13+
14+
@BeforeAll
15+
fun insertTestData() {
16+
transaction {
17+
SimpleVersionedJsonContent.insert(contentId1, "\"{'field1': 'value1'}\"")
18+
SimpleVersionedJsonContent.insert(contentId1, "\"{'field1': 'value2', 'field2': 10}\"")
19+
SimpleVersionedJsonContent.insert(contentId2, "\"{'field1': 'value3', 'field3': 5}\"")
20+
SimpleVersionedJsonContent.insert(contentId2, "\"{'field1': 'value4', 'field4': 7}\"")
21+
}
22+
}
23+
24+
@Test
25+
fun shouldGetNewestContent() {
26+
transaction {
27+
assertThat(SimpleVersionedJsonContent.findNewestContent(contentId1)).isEqualTo("\"{'field1': 'value2', 'field2': 10}\"")
28+
assertThat(SimpleVersionedJsonContent.findNewestContent(contentId2)).isEqualTo("\"{'field1': 'value4', 'field4': 7}\"")
29+
assertThat(SimpleVersionedJsonContent.findNewestContent(ContentId.generate())).isNull()
30+
}
31+
}
32+
33+
@Test
34+
fun shouldGetOldestContent() {
35+
transaction {
36+
assertThat(SimpleVersionedJsonContent.findOldestContent(contentId1)).isEqualTo("\"{'field1': 'value1'}\"")
37+
assertThat(SimpleVersionedJsonContent.findOldestContent(contentId2)).isEqualTo("\"{'field1': 'value3', 'field3': 5}\"")
38+
assertThat(SimpleVersionedJsonContent.findOldestContent(ContentId.generate())).isNull()
39+
}
40+
}
41+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
package pl.touk.krush.distincton
2+
3+
import org.assertj.core.api.Assertions.assertThat
4+
import org.jetbrains.exposed.sql.transactions.transaction
5+
import org.junit.jupiter.api.Test
6+
import pl.touk.krush.base.BaseDatabaseTest
7+
8+
class TenantVersionedJsonContentTest : BaseDatabaseTest() {
9+
10+
@Test
11+
fun shouldGetNewestContents() {
12+
val contentId1 = ContentId.generate()
13+
val contentId2 = ContentId.generate()
14+
15+
val tenantId1 = TenantId.generate()
16+
val tenantId2 = TenantId.generate()
17+
18+
transaction {
19+
TenantVersionedJsonContent.insert(TenantJsonContent(contentId1, tenantId1, "\"{'field1': 'value1'}\""))
20+
TenantVersionedJsonContent.insert(TenantJsonContent(contentId1, tenantId1, "\"{'field1': 'value2', 'field2': 10}\""))
21+
TenantVersionedJsonContent.insert(TenantJsonContent(contentId2, tenantId1, "\"{'field1': 'value3', 'field3': 5}\""))
22+
TenantVersionedJsonContent.insert(TenantJsonContent(contentId1, tenantId2, "\"{'field1': 'value4', 'field3': 7}\""))
23+
TenantVersionedJsonContent.insert(TenantJsonContent(contentId1, tenantId2, "\"{'field1': 'value5', 'field3': 7}\""))
24+
}
25+
26+
transaction {
27+
assertThat(TenantVersionedJsonContent.getNewestContents()).containsExactlyInAnyOrder(
28+
TenantJsonContent(contentId1, tenantId1, "\"{'field1': 'value2', 'field2': 10}\""),
29+
TenantJsonContent(contentId2, tenantId1, "\"{'field1': 'value3', 'field3': 5}\""),
30+
TenantJsonContent(contentId1, tenantId2, "\"{'field1': 'value5', 'field3': 7}\""),
31+
)
32+
}
33+
}
34+
}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
package pl.touk.krush
2+
3+
import org.jetbrains.exposed.sql.Column
4+
import org.jetbrains.exposed.sql.Expression
5+
import org.jetbrains.exposed.sql.ExpressionWithColumnType
6+
import org.jetbrains.exposed.sql.FieldSet
7+
import org.jetbrains.exposed.sql.Function
8+
import org.jetbrains.exposed.sql.QueryBuilder
9+
import org.jetbrains.exposed.sql.Slice
10+
import org.jetbrains.exposed.sql.Table
11+
12+
/**
13+
* There is no postgresql distinct on implemented yet (https://github.com/JetBrains/Exposed/issues/500)
14+
*
15+
* Current implementation assumes that:
16+
* - `distinct on` can be used on single or multiple number of columns
17+
* - all columns of `distinct on` clause are also columns present in the query
18+
* - each column of `distinct on` clause can be referenced normally in `order by` etc.
19+
*
20+
* To allow above:
21+
* - custom slice is introduced because it is less intrusive way to extend exposed with `distinct on` clause
22+
* - after `distinct on (...)` there will be `TRUE` part added
23+
*/
24+
25+
fun <T> Column<T>.distinctOn(): DistinctOnColumns<T> = DistinctOnColumns(this)
26+
27+
fun <T> distinctOn(expr: ExpressionWithColumnType<T>, vararg others: Expression<*>) = DistinctOnColumns(expr, *others)
28+
29+
fun Table.slice(distinctOnColumns: DistinctOnColumns<*>, vararg columns: Expression<*>): FieldSet {
30+
val distinctOn = distinctOnColumns.toDistinctOn()
31+
return Slice(this, listOf(distinctOn) + distinctOn.fieldset() + columns)
32+
}
33+
34+
class DistinctOnColumns<T>(
35+
private val expr: ExpressionWithColumnType<T>,
36+
private vararg val others: Expression<*>,
37+
) {
38+
fun toDistinctOn() = DistinctOn(expr, *others)
39+
}
40+
41+
class DistinctOn<T>(
42+
private val expr: ExpressionWithColumnType<T>,
43+
private vararg val others: Expression<*>,
44+
) : Function<T>(expr.columnType) {
45+
override fun toQueryBuilder(queryBuilder: QueryBuilder): Unit = queryBuilder {
46+
append("DISTINCT ON(")
47+
(fieldset()).appendTo(separator = ", ") { +it }
48+
append(") TRUE")
49+
}
50+
51+
fun fieldset() = listOf(expr) + others
52+
}

0 commit comments

Comments
 (0)